emdash-core 0.1.25__py3-none-any.whl → 0.1.33__py3-none-any.whl
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.
- emdash_core/agent/__init__.py +4 -0
- emdash_core/agent/events.py +42 -20
- emdash_core/agent/inprocess_subagent.py +123 -10
- emdash_core/agent/prompts/__init__.py +4 -3
- emdash_core/agent/prompts/main_agent.py +32 -2
- emdash_core/agent/prompts/plan_mode.py +236 -107
- emdash_core/agent/prompts/subagents.py +79 -15
- emdash_core/agent/prompts/workflow.py +145 -26
- emdash_core/agent/providers/factory.py +2 -2
- emdash_core/agent/providers/openai_provider.py +67 -15
- emdash_core/agent/runner/__init__.py +49 -0
- emdash_core/agent/runner/agent_runner.py +753 -0
- emdash_core/agent/runner/context.py +451 -0
- emdash_core/agent/runner/factory.py +108 -0
- emdash_core/agent/runner/plan.py +217 -0
- emdash_core/agent/runner/sdk_runner.py +324 -0
- emdash_core/agent/runner/utils.py +67 -0
- emdash_core/agent/skills.py +47 -8
- emdash_core/agent/toolkit.py +46 -14
- emdash_core/agent/toolkits/plan.py +9 -11
- emdash_core/agent/tools/__init__.py +2 -2
- emdash_core/agent/tools/coding.py +48 -4
- emdash_core/agent/tools/modes.py +151 -143
- emdash_core/agent/tools/task.py +41 -2
- emdash_core/api/agent.py +555 -1
- emdash_core/skills/frontend-design/SKILL.md +56 -0
- emdash_core/sse/stream.py +4 -0
- {emdash_core-0.1.25.dist-info → emdash_core-0.1.33.dist-info}/METADATA +2 -1
- {emdash_core-0.1.25.dist-info → emdash_core-0.1.33.dist-info}/RECORD +31 -24
- emdash_core/agent/runner.py +0 -1123
- {emdash_core-0.1.25.dist-info → emdash_core-0.1.33.dist-info}/WHEEL +0 -0
- {emdash_core-0.1.25.dist-info → emdash_core-0.1.33.dist-info}/entry_points.txt +0 -0
emdash_core/agent/skills.py
CHANGED
|
@@ -6,6 +6,10 @@ when relevant or explicitly invoked via /skill_name.
|
|
|
6
6
|
|
|
7
7
|
Similar to Claude Code's skills system:
|
|
8
8
|
https://docs.anthropic.com/en/docs/claude-code/skills
|
|
9
|
+
|
|
10
|
+
Skills are loaded from two locations:
|
|
11
|
+
1. Built-in skills bundled with emdash_core (always available)
|
|
12
|
+
2. User repo skills in .emdash/skills/ (can override built-in)
|
|
9
13
|
"""
|
|
10
14
|
|
|
11
15
|
from dataclasses import dataclass, field
|
|
@@ -15,6 +19,11 @@ from typing import Optional
|
|
|
15
19
|
from ..utils.logger import log
|
|
16
20
|
|
|
17
21
|
|
|
22
|
+
def _get_builtin_skills_dir() -> Path:
|
|
23
|
+
"""Get the directory containing built-in skills bundled with emdash_core."""
|
|
24
|
+
return Path(__file__).parent.parent / "skills"
|
|
25
|
+
|
|
26
|
+
|
|
18
27
|
@dataclass
|
|
19
28
|
class Skill:
|
|
20
29
|
"""A skill configuration loaded from SKILL.md.
|
|
@@ -26,6 +35,7 @@ class Skill:
|
|
|
26
35
|
tools: List of tools this skill needs access to
|
|
27
36
|
user_invocable: Whether skill can be invoked with /name
|
|
28
37
|
file_path: Source file path
|
|
38
|
+
_builtin: Whether this is a built-in skill bundled with emdash_core
|
|
29
39
|
"""
|
|
30
40
|
|
|
31
41
|
name: str
|
|
@@ -34,6 +44,7 @@ class Skill:
|
|
|
34
44
|
tools: list[str] = field(default_factory=list)
|
|
35
45
|
user_invocable: bool = False
|
|
36
46
|
file_path: Optional[Path] = None
|
|
47
|
+
_builtin: bool = False
|
|
37
48
|
|
|
38
49
|
|
|
39
50
|
class SkillRegistry:
|
|
@@ -67,7 +78,11 @@ class SkillRegistry:
|
|
|
67
78
|
cls._instance._skills_dir = None
|
|
68
79
|
|
|
69
80
|
def load_skills(self, skills_dir: Optional[Path] = None) -> dict[str, Skill]:
|
|
70
|
-
"""Load skills from
|
|
81
|
+
"""Load skills from built-in and user repo directories.
|
|
82
|
+
|
|
83
|
+
Skills are loaded from two locations (in order):
|
|
84
|
+
1. Built-in skills bundled with emdash_core (always available)
|
|
85
|
+
2. User repo skills in .emdash/skills/ (can override built-in)
|
|
71
86
|
|
|
72
87
|
Each skill is a directory containing a SKILL.md file:
|
|
73
88
|
|
|
@@ -94,7 +109,7 @@ class SkillRegistry:
|
|
|
94
109
|
```
|
|
95
110
|
|
|
96
111
|
Args:
|
|
97
|
-
skills_dir: Directory containing skill subdirectories.
|
|
112
|
+
skills_dir: Directory containing user skill subdirectories.
|
|
98
113
|
Defaults to .emdash/skills/ in cwd.
|
|
99
114
|
|
|
100
115
|
Returns:
|
|
@@ -105,9 +120,34 @@ class SkillRegistry:
|
|
|
105
120
|
|
|
106
121
|
self._skills_dir = skills_dir
|
|
107
122
|
|
|
108
|
-
|
|
109
|
-
|
|
123
|
+
skills = {}
|
|
124
|
+
|
|
125
|
+
# First, load built-in skills bundled with emdash_core
|
|
126
|
+
builtin_dir = _get_builtin_skills_dir()
|
|
127
|
+
if builtin_dir.exists():
|
|
128
|
+
builtin_skills = self._load_skills_from_dir(builtin_dir, is_builtin=True)
|
|
129
|
+
skills.update(builtin_skills)
|
|
130
|
+
|
|
131
|
+
# Then, load user repo skills (can override built-in)
|
|
132
|
+
if skills_dir.exists():
|
|
133
|
+
user_skills = self._load_skills_from_dir(skills_dir, is_builtin=False)
|
|
134
|
+
skills.update(user_skills)
|
|
110
135
|
|
|
136
|
+
if skills:
|
|
137
|
+
log.info(f"Loaded {len(skills)} skills ({len([s for s in self._skills.values() if getattr(s, '_builtin', False)])} built-in)")
|
|
138
|
+
|
|
139
|
+
return skills
|
|
140
|
+
|
|
141
|
+
def _load_skills_from_dir(self, skills_dir: Path, is_builtin: bool = False) -> dict[str, Skill]:
|
|
142
|
+
"""Load skills from a specific directory.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
skills_dir: Directory containing skill subdirectories
|
|
146
|
+
is_builtin: Whether these are built-in skills
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Dict mapping skill name to Skill
|
|
150
|
+
"""
|
|
111
151
|
skills = {}
|
|
112
152
|
|
|
113
153
|
# Look for SKILL.md in subdirectories
|
|
@@ -125,15 +165,14 @@ class SkillRegistry:
|
|
|
125
165
|
try:
|
|
126
166
|
skill = _parse_skill_file(skill_file, skill_dir.name)
|
|
127
167
|
if skill:
|
|
168
|
+
skill._builtin = is_builtin # Mark as built-in or user-defined
|
|
128
169
|
skills[skill.name] = skill
|
|
129
170
|
self._skills[skill.name] = skill
|
|
130
|
-
|
|
171
|
+
source = "built-in" if is_builtin else "user"
|
|
172
|
+
log.debug(f"Loaded {source} skill: {skill.name}")
|
|
131
173
|
except Exception as e:
|
|
132
174
|
log.warning(f"Failed to load skill from {skill_file}: {e}")
|
|
133
175
|
|
|
134
|
-
if skills:
|
|
135
|
-
log.info(f"Loaded {len(skills)} skills")
|
|
136
|
-
|
|
137
176
|
return skills
|
|
138
177
|
|
|
139
178
|
def get_skill(self, name: str) -> Optional[Skill]:
|
emdash_core/agent/toolkit.py
CHANGED
|
@@ -41,6 +41,7 @@ class AgentToolkit:
|
|
|
41
41
|
repo_root: Optional[Path] = None,
|
|
42
42
|
plan_mode: bool = False,
|
|
43
43
|
save_spec_path: Optional[Path] = None,
|
|
44
|
+
plan_file_path: Optional[str] = None,
|
|
44
45
|
):
|
|
45
46
|
"""Initialize the agent toolkit.
|
|
46
47
|
|
|
@@ -53,6 +54,7 @@ class AgentToolkit:
|
|
|
53
54
|
If None, uses repo_root from config or current working directory.
|
|
54
55
|
plan_mode: Whether to include spec planning tools and restrict to read-only.
|
|
55
56
|
save_spec_path: If provided, specs will be saved to this path.
|
|
57
|
+
plan_file_path: Path to the plan file (only writable file in plan mode).
|
|
56
58
|
"""
|
|
57
59
|
self.connection = connection or get_connection()
|
|
58
60
|
self.session = AgentSession() if enable_session else None
|
|
@@ -61,6 +63,7 @@ class AgentToolkit:
|
|
|
61
63
|
self._mcp_config_path = mcp_config_path
|
|
62
64
|
self.plan_mode = plan_mode
|
|
63
65
|
self.save_spec_path = save_spec_path
|
|
66
|
+
self.plan_file_path = plan_file_path
|
|
64
67
|
|
|
65
68
|
# Get repo_root from config if not explicitly provided
|
|
66
69
|
if repo_root is None:
|
|
@@ -70,8 +73,12 @@ class AgentToolkit:
|
|
|
70
73
|
repo_root = Path(config.repo_root)
|
|
71
74
|
self._repo_root = repo_root or Path.cwd()
|
|
72
75
|
|
|
73
|
-
# Configure spec state if plan mode
|
|
76
|
+
# Configure mode state and spec state if plan mode
|
|
74
77
|
if plan_mode:
|
|
78
|
+
from .tools.modes import ModeState, AgentMode
|
|
79
|
+
mode_state = ModeState.get_instance()
|
|
80
|
+
mode_state.current_mode = AgentMode.PLAN
|
|
81
|
+
|
|
75
82
|
from .tools.spec import SpecState
|
|
76
83
|
spec_state = SpecState.get_instance()
|
|
77
84
|
spec_state.configure(save_path=save_spec_path)
|
|
@@ -110,8 +117,18 @@ class AgentToolkit:
|
|
|
110
117
|
self.register_tool(ReadFileTool(self._repo_root, self.connection))
|
|
111
118
|
self.register_tool(ListFilesTool(self._repo_root, self.connection))
|
|
112
119
|
|
|
113
|
-
# Register write tools
|
|
114
|
-
if
|
|
120
|
+
# Register write tools
|
|
121
|
+
if self.plan_mode:
|
|
122
|
+
# In plan mode: only allow writing to the plan file
|
|
123
|
+
if self.plan_file_path:
|
|
124
|
+
from .tools.coding import WriteToFileTool
|
|
125
|
+
self.register_tool(WriteToFileTool(
|
|
126
|
+
self._repo_root,
|
|
127
|
+
self.connection,
|
|
128
|
+
allowed_paths=[self.plan_file_path],
|
|
129
|
+
))
|
|
130
|
+
else:
|
|
131
|
+
# In code mode: full write access
|
|
115
132
|
from .tools.coding import (
|
|
116
133
|
WriteToFileTool,
|
|
117
134
|
ApplyDiffTool,
|
|
@@ -129,8 +146,12 @@ class AgentToolkit:
|
|
|
129
146
|
# Register mode tools
|
|
130
147
|
self._register_mode_tools()
|
|
131
148
|
|
|
132
|
-
# Register task management tools
|
|
133
|
-
|
|
149
|
+
# Register task management tools
|
|
150
|
+
# In plan mode: only register ask_followup_question for clarifications
|
|
151
|
+
# In code mode: register all task tools
|
|
152
|
+
if self.plan_mode:
|
|
153
|
+
self._register_plan_mode_task_tools()
|
|
154
|
+
else:
|
|
134
155
|
self._register_task_tools()
|
|
135
156
|
|
|
136
157
|
# Register spec planning tools (only in plan mode)
|
|
@@ -162,21 +183,32 @@ class AgentToolkit:
|
|
|
162
183
|
def _register_mode_tools(self) -> None:
|
|
163
184
|
"""Register mode switching tools.
|
|
164
185
|
|
|
165
|
-
-
|
|
166
|
-
- exit_plan: Available in
|
|
186
|
+
- enter_plan_mode: Available in code mode to request entering plan mode
|
|
187
|
+
- exit_plan: Available in both modes to submit plan for approval
|
|
167
188
|
- get_mode: Always available to check current mode
|
|
168
189
|
"""
|
|
169
|
-
from .tools.modes import
|
|
190
|
+
from .tools.modes import EnterPlanModeTool, ExitPlanModeTool, GetModeTool
|
|
170
191
|
|
|
171
192
|
# get_mode is always available
|
|
172
193
|
self.register_tool(GetModeTool())
|
|
173
194
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
195
|
+
# exit_plan is available in both modes:
|
|
196
|
+
# - In plan mode: submit plan written to plan file
|
|
197
|
+
# - In code mode: submit plan received from Plan subagent
|
|
198
|
+
self.register_tool(ExitPlanModeTool())
|
|
199
|
+
|
|
200
|
+
if not self.plan_mode:
|
|
201
|
+
# In code mode: can also request to enter plan mode
|
|
202
|
+
self.register_tool(EnterPlanModeTool())
|
|
203
|
+
|
|
204
|
+
def _register_plan_mode_task_tools(self) -> None:
|
|
205
|
+
"""Register subset of task tools for plan mode.
|
|
206
|
+
|
|
207
|
+
In plan mode, the agent can ask clarifying questions but
|
|
208
|
+
doesn't need completion/todo tools since exit_plan handles that.
|
|
209
|
+
"""
|
|
210
|
+
from .tools.tasks import AskFollowupQuestionTool
|
|
211
|
+
self.register_tool(AskFollowupQuestionTool())
|
|
180
212
|
|
|
181
213
|
def _register_task_tools(self) -> None:
|
|
182
214
|
"""Register task management tools.
|
|
@@ -1,19 +1,22 @@
|
|
|
1
|
-
"""Plan toolkit - exploration tools
|
|
1
|
+
"""Plan toolkit - read-only exploration tools for planning.
|
|
2
|
+
|
|
3
|
+
The Plan subagent explores the codebase and returns a plan as text.
|
|
4
|
+
The main agent (in plan mode) writes the plan to .emdash/<feature>.md.
|
|
5
|
+
"""
|
|
2
6
|
|
|
3
7
|
from pathlib import Path
|
|
4
8
|
|
|
5
9
|
from .base import BaseToolkit
|
|
6
10
|
from ..tools.coding import ReadFileTool, ListFilesTool
|
|
7
11
|
from ..tools.search import SemanticSearchTool, GrepTool, GlobTool
|
|
8
|
-
from ..tools.plan_write import WritePlanTool
|
|
9
12
|
from ...utils.logger import log
|
|
10
13
|
|
|
11
14
|
|
|
12
15
|
class PlanToolkit(BaseToolkit):
|
|
13
|
-
"""
|
|
16
|
+
"""Read-only toolkit for Plan subagent.
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
The Plan subagent explores the codebase and returns a structured plan.
|
|
19
|
+
It does NOT write files - the main agent handles that.
|
|
17
20
|
|
|
18
21
|
Tools available:
|
|
19
22
|
- read_file: Read file contents
|
|
@@ -21,7 +24,6 @@ class PlanToolkit(BaseToolkit):
|
|
|
21
24
|
- glob: Find files by pattern
|
|
22
25
|
- grep: Search file contents
|
|
23
26
|
- semantic_search: AI-powered code search
|
|
24
|
-
- write_plan: Write implementation plans (restricted to .emdash/plans/)
|
|
25
27
|
"""
|
|
26
28
|
|
|
27
29
|
TOOLS = [
|
|
@@ -30,11 +32,10 @@ class PlanToolkit(BaseToolkit):
|
|
|
30
32
|
"glob",
|
|
31
33
|
"grep",
|
|
32
34
|
"semantic_search",
|
|
33
|
-
"write_plan",
|
|
34
35
|
]
|
|
35
36
|
|
|
36
37
|
def _register_tools(self) -> None:
|
|
37
|
-
"""Register exploration
|
|
38
|
+
"""Register read-only exploration tools."""
|
|
38
39
|
# All read-only exploration tools
|
|
39
40
|
self.register_tool(ReadFileTool(repo_root=self.repo_root))
|
|
40
41
|
self.register_tool(ListFilesTool(repo_root=self.repo_root))
|
|
@@ -49,7 +50,4 @@ class PlanToolkit(BaseToolkit):
|
|
|
49
50
|
except Exception as e:
|
|
50
51
|
log.debug(f"Semantic search not available: {e}")
|
|
51
52
|
|
|
52
|
-
# Special: can only write to .emdash/plans/*.md
|
|
53
|
-
self.register_tool(WritePlanTool(repo_root=self.repo_root))
|
|
54
|
-
|
|
55
53
|
log.debug(f"PlanToolkit registered {len(self._tools)} tools")
|
|
@@ -43,7 +43,7 @@ from .tasks import (
|
|
|
43
43
|
from .plan import PlanExplorationTool
|
|
44
44
|
|
|
45
45
|
# Mode tools
|
|
46
|
-
from .modes import AgentMode, ModeState,
|
|
46
|
+
from .modes import AgentMode, ModeState, EnterPlanModeTool, ExitPlanModeTool, GetModeTool
|
|
47
47
|
|
|
48
48
|
# Spec tools
|
|
49
49
|
from .spec import SubmitSpecTool, GetSpecTool, UpdateSpecTool
|
|
@@ -111,7 +111,7 @@ __all__ = [
|
|
|
111
111
|
# Mode
|
|
112
112
|
"AgentMode",
|
|
113
113
|
"ModeState",
|
|
114
|
-
"
|
|
114
|
+
"EnterPlanModeTool",
|
|
115
115
|
"ExitPlanModeTool",
|
|
116
116
|
"GetModeTool",
|
|
117
117
|
# Spec
|
|
@@ -64,6 +64,8 @@ Returns the file content as text."""
|
|
|
64
64
|
path: str,
|
|
65
65
|
start_line: Optional[int] = None,
|
|
66
66
|
end_line: Optional[int] = None,
|
|
67
|
+
offset: Optional[int] = None,
|
|
68
|
+
limit: Optional[int] = None,
|
|
67
69
|
) -> ToolResult:
|
|
68
70
|
"""Read a file.
|
|
69
71
|
|
|
@@ -71,6 +73,8 @@ Returns the file content as text."""
|
|
|
71
73
|
path: Path to the file
|
|
72
74
|
start_line: Optional starting line (1-indexed)
|
|
73
75
|
end_line: Optional ending line (1-indexed)
|
|
76
|
+
offset: Alternative to start_line - line number to start from (1-indexed)
|
|
77
|
+
limit: Alternative to end_line - number of lines to read
|
|
74
78
|
|
|
75
79
|
Returns:
|
|
76
80
|
ToolResult with file content
|
|
@@ -89,8 +93,14 @@ Returns the file content as text."""
|
|
|
89
93
|
content = full_path.read_text()
|
|
90
94
|
lines = content.split("\n")
|
|
91
95
|
|
|
92
|
-
# Handle line ranges
|
|
93
|
-
|
|
96
|
+
# Handle line ranges - support both start_line/end_line and offset/limit
|
|
97
|
+
# offset/limit take precedence if provided
|
|
98
|
+
if offset is not None or limit is not None:
|
|
99
|
+
start_idx = (offset - 1) if offset else 0
|
|
100
|
+
end_idx = start_idx + limit if limit else len(lines)
|
|
101
|
+
lines = lines[start_idx:end_idx]
|
|
102
|
+
content = "\n".join(lines)
|
|
103
|
+
elif start_line or end_line:
|
|
94
104
|
start_idx = (start_line - 1) if start_line else 0
|
|
95
105
|
end_idx = end_line if end_line else len(lines)
|
|
96
106
|
lines = lines[start_idx:end_idx]
|
|
@@ -123,6 +133,14 @@ Returns the file content as text."""
|
|
|
123
133
|
"type": "integer",
|
|
124
134
|
"description": "Ending line number (1-indexed)",
|
|
125
135
|
},
|
|
136
|
+
"offset": {
|
|
137
|
+
"type": "integer",
|
|
138
|
+
"description": "Line number to start reading from (1-indexed). Alternative to start_line.",
|
|
139
|
+
},
|
|
140
|
+
"limit": {
|
|
141
|
+
"type": "integer",
|
|
142
|
+
"description": "Number of lines to read. Alternative to end_line.",
|
|
143
|
+
},
|
|
126
144
|
},
|
|
127
145
|
required=["path"],
|
|
128
146
|
)
|
|
@@ -135,6 +153,18 @@ class WriteToFileTool(CodingTool):
|
|
|
135
153
|
description = """Write content to a file.
|
|
136
154
|
Creates the file if it doesn't exist, or overwrites if it does."""
|
|
137
155
|
|
|
156
|
+
def __init__(self, repo_root: Path, connection=None, allowed_paths: list[str] | None = None):
|
|
157
|
+
"""Initialize with optional path restrictions.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
repo_root: Root directory of the repository
|
|
161
|
+
connection: Optional connection (not used for file ops)
|
|
162
|
+
allowed_paths: If provided, only these paths can be written to.
|
|
163
|
+
Used in plan mode to restrict writes to the plan file.
|
|
164
|
+
"""
|
|
165
|
+
super().__init__(repo_root, connection)
|
|
166
|
+
self.allowed_paths = allowed_paths
|
|
167
|
+
|
|
138
168
|
def execute(
|
|
139
169
|
self,
|
|
140
170
|
path: str,
|
|
@@ -153,6 +183,19 @@ Creates the file if it doesn't exist, or overwrites if it does."""
|
|
|
153
183
|
if not valid:
|
|
154
184
|
return ToolResult.error_result(error)
|
|
155
185
|
|
|
186
|
+
# Check allowed paths restriction (used in plan mode)
|
|
187
|
+
if self.allowed_paths is not None:
|
|
188
|
+
path_str = str(full_path)
|
|
189
|
+
is_allowed = any(
|
|
190
|
+
path_str == allowed or path_str.endswith(allowed.lstrip("/"))
|
|
191
|
+
for allowed in self.allowed_paths
|
|
192
|
+
)
|
|
193
|
+
if not is_allowed:
|
|
194
|
+
return ToolResult.error_result(
|
|
195
|
+
f"In plan mode, you can only write to: {', '.join(self.allowed_paths)}",
|
|
196
|
+
suggestions=["Write your plan to the designated plan file"],
|
|
197
|
+
)
|
|
198
|
+
|
|
156
199
|
try:
|
|
157
200
|
# Create parent directories
|
|
158
201
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -218,8 +261,9 @@ The diff should be in standard unified diff format."""
|
|
|
218
261
|
|
|
219
262
|
try:
|
|
220
263
|
# Try to apply with patch command
|
|
264
|
+
# --batch: suppress questions, --forward: skip already-applied patches
|
|
221
265
|
result = subprocess.run(
|
|
222
|
-
["patch", "-p0", "--forward"],
|
|
266
|
+
["patch", "-p0", "--forward", "--batch"],
|
|
223
267
|
input=diff,
|
|
224
268
|
capture_output=True,
|
|
225
269
|
text=True,
|
|
@@ -230,7 +274,7 @@ The diff should be in standard unified diff format."""
|
|
|
230
274
|
if result.returncode != 0:
|
|
231
275
|
# Try with -p1
|
|
232
276
|
result = subprocess.run(
|
|
233
|
-
["patch", "-p1", "--forward"],
|
|
277
|
+
["patch", "-p1", "--forward", "--batch"],
|
|
234
278
|
input=diff,
|
|
235
279
|
capture_output=True,
|
|
236
280
|
text=True,
|