shellmate-cli 3.0.0__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.
Files changed (106) hide show
  1. shellmate/__init__.py +0 -0
  2. shellmate/adapters/__init__.py +67 -0
  3. shellmate/adapters/bash_adapter.sh +7 -0
  4. shellmate/adapters/ble_adapter.sh +350 -0
  5. shellmate/adapters/powershell_adapter.ps1 +417 -0
  6. shellmate/adapters/powershell_predictor.ps1 +97 -0
  7. shellmate/adapters/zsh_adapter.zsh +8 -0
  8. shellmate/agents/__init__.py +23 -0
  9. shellmate/agents/base.py +248 -0
  10. shellmate/agents/chain_patterns.yaml +98 -0
  11. shellmate/agents/command_chain.py +322 -0
  12. shellmate/agents/context_gatherer.py +175 -0
  13. shellmate/agents/environment.py +363 -0
  14. shellmate/agents/librarian.py +502 -0
  15. shellmate/agents/query.py +415 -0
  16. shellmate/agents/repomix_backend.py +171 -0
  17. shellmate/agents/watcher.py +266 -0
  18. shellmate/cache/__init__.py +0 -0
  19. shellmate/cache/semantic_cache.py +41 -0
  20. shellmate/cli/__init__.py +0 -0
  21. shellmate/cli/commands/__init__.py +27 -0
  22. shellmate/cli/commands/analyze.py +5 -0
  23. shellmate/cli/commands/config.py +319 -0
  24. shellmate/cli/commands/configure.py +118 -0
  25. shellmate/cli/commands/disable.py +11 -0
  26. shellmate/cli/commands/enable.py +49 -0
  27. shellmate/cli/commands/init.py +412 -0
  28. shellmate/cli/commands/install.py +242 -0
  29. shellmate/cli/commands/migrate.py +147 -0
  30. shellmate/cli/commands/status.py +31 -0
  31. shellmate/cli/commands/uninstall.py +78 -0
  32. shellmate/cli/main.py +114 -0
  33. shellmate/cli/parser.py +184 -0
  34. shellmate/cli/version.py +1 -0
  35. shellmate/completion/__init__.py +4 -0
  36. shellmate/completion/bash_adapter.py +145 -0
  37. shellmate/config/__init__.py +33 -0
  38. shellmate/config/credentials.py +120 -0
  39. shellmate/config/defaults.py +88 -0
  40. shellmate/config/manager.py +94 -0
  41. shellmate/config/paths.py +77 -0
  42. shellmate/config/secrets.py +66 -0
  43. shellmate/daemon/__init__.py +0 -0
  44. shellmate/daemon/lifecycle.py +308 -0
  45. shellmate/daemon/observability.py +82 -0
  46. shellmate/daemon/pipeline.py +119 -0
  47. shellmate/daemon/server.py +605 -0
  48. shellmate/daemon/session.py +170 -0
  49. shellmate/daemon/startup_ui.py +43 -0
  50. shellmate/environment/__init__.py +0 -0
  51. shellmate/environment/detector.py +130 -0
  52. shellmate/ipc/__init__.py +0 -0
  53. shellmate/ipc/client.py +210 -0
  54. shellmate/ipc/ps_client.py +190 -0
  55. shellmate/orchestration/__init__.py +3 -0
  56. shellmate/orchestration/analysis_graph.py +1274 -0
  57. shellmate/orchestration/state.py +35 -0
  58. shellmate/parser/__init__.py +0 -0
  59. shellmate/parser/command_parser.py +111 -0
  60. shellmate/plugins/__init__.py +0 -0
  61. shellmate/plugins/base.py +80 -0
  62. shellmate/plugins/loader.py +41 -0
  63. shellmate/providers/__init__.py +0 -0
  64. shellmate/providers/base.py +97 -0
  65. shellmate/providers/groq_command_guide.py +108 -0
  66. shellmate/providers/groq_provider.py +127 -0
  67. shellmate/providers/groq_supervisor.py +78 -0
  68. shellmate/providers/openrouter_provider.py +149 -0
  69. shellmate/providers/router.py +272 -0
  70. shellmate/providers/validate_keys.py +98 -0
  71. shellmate/providers/xai_provider.py +142 -0
  72. shellmate/rules/__init__.py +0 -0
  73. shellmate/rules/catastrophic.yaml +96 -0
  74. shellmate/rules/catastrophic_matcher.py +167 -0
  75. shellmate/rules/definitions/__init__.py +0 -0
  76. shellmate/rules/definitions/file_deletion.yaml +54 -0
  77. shellmate/rules/definitions/filesystem.yaml +43 -0
  78. shellmate/rules/definitions/network_execution.yaml +46 -0
  79. shellmate/rules/definitions/permissions.yaml +54 -0
  80. shellmate/rules/definitions/process_control.yaml +37 -0
  81. shellmate/rules/definitions/system_destruction.yaml +67 -0
  82. shellmate/rules/engine.py +200 -0
  83. shellmate/sanitizer/__init__.py +0 -0
  84. shellmate/sanitizer/typo_detector.py +114 -0
  85. shellmate/ui/__init__.py +10 -0
  86. shellmate/ui/analysis.py +164 -0
  87. shellmate/ui/annotations.py +232 -0
  88. shellmate/ui/banner.py +49 -0
  89. shellmate/ui/progress.py +126 -0
  90. shellmate/ui/runtime/__init__.py +15 -0
  91. shellmate/ui/runtime/console_factory.py +65 -0
  92. shellmate/ui/runtime/live_scene.py +116 -0
  93. shellmate/ui/runtime/motion_policy.py +69 -0
  94. shellmate/ui/runtime/tokens.py +47 -0
  95. shellmate/ui/scenes/__init__.py +24 -0
  96. shellmate/ui/scenes/enable.py +316 -0
  97. shellmate/ui/scenes/help.py +53 -0
  98. shellmate/ui/scenes/stages.py +128 -0
  99. shellmate/ui/scenes/status.py +181 -0
  100. shellmate/ui/status.py +3 -0
  101. shellmate_cli-3.0.0.dist-info/METADATA +352 -0
  102. shellmate_cli-3.0.0.dist-info/RECORD +106 -0
  103. shellmate_cli-3.0.0.dist-info/WHEEL +5 -0
  104. shellmate_cli-3.0.0.dist-info/entry_points.txt +3 -0
  105. shellmate_cli-3.0.0.dist-info/licenses/LICENSE +21 -0
  106. shellmate_cli-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,248 @@
1
+ import asyncio
2
+ import logging
3
+ import uuid
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Dict, Optional
6
+ from enum import Enum
7
+ from abc import ABC, abstractmethod
8
+
9
+ logger = logging.getLogger("shellmate.agents")
10
+
11
+
12
+ class AgentStatus(Enum):
13
+ STARTING = "starting"
14
+ READY = "ready"
15
+ ERROR = "error"
16
+ STOPPED = "stopped"
17
+
18
+
19
+ @dataclass
20
+ class AgentRequest:
21
+ request_id: str
22
+ command: str
23
+ data: Dict[str, Any] = field(default_factory=dict)
24
+ timeout: float = 5.0
25
+
26
+
27
+ @dataclass
28
+ class AgentResponse:
29
+ request_id: str
30
+ agent_name: str
31
+ status: str
32
+ data: Dict[str, Any] = field(default_factory=dict)
33
+ error: Optional[str] = None
34
+ latency_ms: float = 0.0
35
+
36
+
37
+ class Agent(ABC):
38
+ def __init__(self, name: str):
39
+ self.name = name
40
+ self._status = AgentStatus.STARTING
41
+ self._request_queue: asyncio.Queue = asyncio.Queue()
42
+ self._response_futures: Dict[str, asyncio.Future] = {}
43
+ self._running = False
44
+ self._worker_task: Optional[asyncio.Task] = None
45
+
46
+ @property
47
+ def status(self) -> AgentStatus:
48
+ return self._status
49
+
50
+ @abstractmethod
51
+ async def initialize(self) -> None:
52
+ pass
53
+
54
+ @abstractmethod
55
+ async def process(self, request: AgentRequest) -> AgentResponse:
56
+ pass
57
+
58
+ @abstractmethod
59
+ async def shutdown(self) -> None:
60
+ pass
61
+
62
+ async def start(self) -> None:
63
+ if self._running:
64
+ return
65
+ self._running = True
66
+ await self.initialize()
67
+ self._status = AgentStatus.READY
68
+ self._worker_task = asyncio.create_task(self._worker())
69
+ logger.info(f"Agent {self.name} started")
70
+
71
+ async def stop(self) -> None:
72
+ if not self._running:
73
+ return
74
+ self._running = False
75
+ if self._worker_task:
76
+ self._worker_task.cancel()
77
+ try:
78
+ await self._worker_task
79
+ except asyncio.CancelledError:
80
+ pass
81
+ await self.shutdown()
82
+ self._status = AgentStatus.STOPPED
83
+ logger.info(f"Agent {self.name} stopped")
84
+
85
+ async def _worker(self) -> None:
86
+ while self._running:
87
+ try:
88
+ request = await asyncio.wait_for(
89
+ self._request_queue.get(), timeout=1.0
90
+ )
91
+ await self._handle_request(request)
92
+ except asyncio.TimeoutError:
93
+ continue
94
+ except asyncio.CancelledError:
95
+ break
96
+ except Exception as e:
97
+ logger.error(f"Agent {self.name} worker error: {e}", exc_info=True)
98
+
99
+ async def _handle_request(self, request: AgentRequest) -> None:
100
+ response_future = self._response_futures.get(request.request_id)
101
+ if not response_future:
102
+ logger.warning(f"Agent {self.name}: No response future for request {request.request_id}")
103
+ return
104
+
105
+ start_time = asyncio.get_event_loop().time()
106
+ try:
107
+ response = await asyncio.wait_for(
108
+ self.process(request), timeout=request.timeout
109
+ )
110
+ except asyncio.TimeoutError:
111
+ response = AgentResponse(
112
+ request_id=request.request_id,
113
+ agent_name=self.name,
114
+ status="timeout",
115
+ error=f"Agent {self.name} timed out after {request.timeout}s",
116
+ latency_ms=(asyncio.get_event_loop().time() - start_time) * 1000,
117
+ )
118
+ except Exception as e:
119
+ logger.error(f"Agent {self.name} process error: {e}", exc_info=True)
120
+ response = AgentResponse(
121
+ request_id=request.request_id,
122
+ agent_name=self.name,
123
+ status="error",
124
+ error=str(e),
125
+ latency_ms=(asyncio.get_event_loop().time() - start_time) * 1000,
126
+ )
127
+ else:
128
+ response.latency_ms = (asyncio.get_event_loop().time() - start_time) * 1000
129
+
130
+ if not response_future.done():
131
+ response_future.set_result(response)
132
+
133
+ async def send_request(self, command: str, data: Dict[str, Any] = None, timeout: float = 5.0) -> AgentResponse:
134
+ if not self._running or self._status != AgentStatus.READY:
135
+ return AgentResponse(
136
+ request_id=str(uuid.uuid4()),
137
+ agent_name=self.name,
138
+ status="error",
139
+ error=f"Agent {self.name} not ready",
140
+ )
141
+
142
+ request_id = str(uuid.uuid4())
143
+ request = AgentRequest(
144
+ request_id=request_id,
145
+ command=command,
146
+ data=data or {},
147
+ timeout=timeout,
148
+ )
149
+
150
+ response_future = asyncio.get_running_loop().create_future()
151
+ self._response_futures[request_id] = response_future
152
+
153
+ try:
154
+ await self._request_queue.put(request)
155
+ response = await asyncio.wait_for(response_future, timeout=timeout + 1.0)
156
+ return response
157
+ except asyncio.TimeoutError:
158
+ if not response_future.done():
159
+ response_future.cancel()
160
+ return AgentResponse(
161
+ request_id=request_id,
162
+ agent_name=self.name,
163
+ status="timeout",
164
+ error=f"Request timed out after {timeout}s",
165
+ )
166
+ finally:
167
+ self._response_futures.pop(request_id, None)
168
+
169
+
170
+ class AgentManager:
171
+ def __init__(self):
172
+ self._agents: Dict[str, Agent] = {}
173
+ self._running = False
174
+
175
+ def register(self, agent: Agent) -> None:
176
+ self._agents[agent.name] = agent
177
+
178
+ def get(self, name: str) -> Optional[Agent]:
179
+ return self._agents.get(name)
180
+
181
+ async def start_all(self) -> None:
182
+ if self._running:
183
+ return
184
+ self._running = True
185
+ for agent in self._agents.values():
186
+ await agent.start()
187
+ logger.info("All agents started")
188
+
189
+ async def stop_all(self) -> None:
190
+ if not self._running:
191
+ return
192
+ for agent in self._agents.values():
193
+ await agent.stop()
194
+ self._running = False
195
+ logger.info("All agents stopped")
196
+
197
+ async def send_request(self, agent_name: str, command: str, data: Dict[str, Any] = None, timeout: float = 5.0) -> AgentResponse:
198
+ agent = self._agents.get(agent_name)
199
+ if not agent:
200
+ return AgentResponse(
201
+ request_id=str(uuid.uuid4()),
202
+ agent_name=agent_name,
203
+ status="error",
204
+ error=f"Agent {agent_name} not found",
205
+ )
206
+ return await agent.send_request(command, data, timeout)
207
+
208
+ async def send_concurrent(self, requests: Dict[str, tuple], timeout: float = 5.0) -> Dict[str, AgentResponse]:
209
+ tasks = {}
210
+ for agent_name, (command, data) in requests.items():
211
+ agent = self._agents.get(agent_name)
212
+ if agent:
213
+ tasks[agent_name] = asyncio.create_task(
214
+ agent.send_request(command, data, timeout)
215
+ )
216
+
217
+ results = {}
218
+ for agent_name, task in tasks.items():
219
+ try:
220
+ results[agent_name] = await asyncio.wait_for(task, timeout=timeout + 1.0)
221
+ except asyncio.TimeoutError:
222
+ results[agent_name] = AgentResponse(
223
+ request_id=str(uuid.uuid4()),
224
+ agent_name=agent_name,
225
+ status="timeout",
226
+ error=f"Request timed out after {timeout}s",
227
+ )
228
+ except Exception as e:
229
+ results[agent_name] = AgentResponse(
230
+ request_id=str(uuid.uuid4()),
231
+ agent_name=agent_name,
232
+ status="error",
233
+ error=str(e),
234
+ )
235
+ return results
236
+
237
+ def get_status(self) -> Dict[str, str]:
238
+ return {name: agent.status.value for name, agent in self._agents.items()}
239
+
240
+
241
+ _agent_manager: Optional[AgentManager] = None
242
+
243
+
244
+ def get_agent_manager() -> AgentManager:
245
+ global _agent_manager
246
+ if _agent_manager is None:
247
+ _agent_manager = AgentManager()
248
+ return _agent_manager
@@ -0,0 +1,98 @@
1
+ patterns:
2
+ # Terraform workflows
3
+ - name: terraform_workflow
4
+ sequence:
5
+ - pattern: "terraform plan"
6
+ required: true
7
+ - pattern: "terraform apply"
8
+ expected: true
9
+ risk_if_broken: HIGH
10
+ description: "Terraform apply should follow plan"
11
+
12
+ # Git workflows
13
+ - name: git_force_push_workflow
14
+ sequence:
15
+ - pattern: "git (pull|fetch)"
16
+ required: true
17
+ - pattern: "git push.*--force"
18
+ expected: true
19
+ risk_if_broken: CRITICAL
20
+ description: "Force push should follow pull/fetch"
21
+
22
+ - name: git_commit_workflow
23
+ sequence:
24
+ - pattern: "git add"
25
+ required: true
26
+ - pattern: "git commit"
27
+ expected: true
28
+ - pattern: "git push"
29
+ expected: true
30
+ risk_if_broken: MEDIUM
31
+ description: "Standard git workflow: add → commit → push"
32
+
33
+ # Kubernetes workflows
34
+ - name: kubernetes_apply_workflow
35
+ sequence:
36
+ - pattern: "kubectl (create|apply) configmap"
37
+ required: false
38
+ - pattern: "kubectl apply.*deployment"
39
+ expected: true
40
+ risk_if_broken: HIGH
41
+ description: "Apply deployment after configmap"
42
+
43
+ # Docker workflows
44
+ - name: docker_build_push_workflow
45
+ sequence:
46
+ - pattern: "docker build"
47
+ required: true
48
+ - pattern: "docker tag"
49
+ required: true
50
+ - pattern: "docker push"
51
+ expected: true
52
+ risk_if_broken: MEDIUM
53
+ description: "Docker image should be built and tagged before push"
54
+
55
+ - name: docker_compose_workflow
56
+ sequence:
57
+ - pattern: "docker-compose (build|pull)"
58
+ required: true
59
+ - pattern: "docker-compose up"
60
+ expected: true
61
+ risk_if_broken: LOW
62
+ description: "Docker compose up should follow build/pull"
63
+
64
+ # Database workflows
65
+ - name: database_backup_workflow
66
+ sequence:
67
+ - pattern: "(pg_dump|mysqldump|mongodump)"
68
+ required: true
69
+ - pattern: "DROP DATABASE"
70
+ expected: true
71
+ risk_if_broken: CRITICAL
72
+ description: "Database should be backed up before dropping"
73
+
74
+ # Dangerous escalations
75
+ - name: dangerous_escalation_rm
76
+ sequence:
77
+ - pattern: "cd (/|/home|/var|/etc|/usr)"
78
+ required: true
79
+ - pattern: "rm.*-rf"
80
+ dangerous: true
81
+ risk_if_detected: CRITICAL
82
+ description: "rm -rf in sensitive directory"
83
+
84
+ - name: dangerous_escalation_chmod
85
+ sequence:
86
+ - pattern: "chmod (777|666)"
87
+ dangerous: true
88
+ risk_if_detected: CRITICAL
89
+ description: "Overly permissive chmod"
90
+
91
+ - name: dangerous_sudo_escalation
92
+ sequence:
93
+ - pattern: "sudo su"
94
+ required: true
95
+ - pattern: "(rm|del|DROP|truncate)"
96
+ dangerous: true
97
+ risk_if_detected: CRITICAL
98
+ description: "Destructive command after sudo su"
@@ -0,0 +1,322 @@
1
+ """
2
+ CommandChainAgent: Tracks command history and detects workflow patterns.
3
+
4
+ Maintains a persistent JSONL history of commands and analyzes patterns
5
+ to detect expected workflows, broken workflows, and dangerous escalations.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import re
11
+ import time
12
+ from datetime import datetime, timedelta
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ import yaml
17
+
18
+ from shellmate.agents.base import Agent, AgentRequest, AgentResponse
19
+ from shellmate.config.defaults import get_default_config
20
+ from shellmate.daemon.session import get_command_history_file
21
+
22
+
23
+ class CommandChainAgent(Agent):
24
+ """Tracks command history and detects workflow patterns."""
25
+
26
+ def __init__(self):
27
+ super().__init__(name="command_chain")
28
+ self.history_file = get_command_history_file()
29
+ self.patterns = []
30
+ self.patterns_file = Path(__file__).parent / "chain_patterns.yaml"
31
+
32
+ async def initialize(self) -> None:
33
+ """Initialize the agent and load pattern definitions."""
34
+ self._load_patterns()
35
+
36
+ async def shutdown(self) -> None:
37
+ """Shutdown the agent."""
38
+ pass
39
+
40
+ async def process(self, request: AgentRequest) -> AgentResponse:
41
+ """Process agent requests by dispatching to appropriate methods."""
42
+ start_time = time.time()
43
+
44
+ try:
45
+ if request.command == "append_command":
46
+ data = request.data
47
+ await self.append_command(
48
+ data.get("command", ""),
49
+ data.get("risk", "MEDIUM"),
50
+ data.get("exit_code")
51
+ )
52
+ latency = (time.time() - start_time) * 1000
53
+ return AgentResponse(
54
+ request.request_id,
55
+ self.name,
56
+ status="ok",
57
+ data={"success": True},
58
+ latency_ms=latency,
59
+ )
60
+ elif request.command == "get_chain_patterns":
61
+ data = request.data
62
+ recent_commands = await self.get_recent_commands(
63
+ count=data.get("count", 10),
64
+ minutes=data.get("minutes", 30)
65
+ )
66
+ current_command = data.get("command", "")
67
+ patterns = await self.detect_patterns(recent_commands, current_command)
68
+ latency = (time.time() - start_time) * 1000
69
+ return AgentResponse(
70
+ request.request_id,
71
+ self.name,
72
+ status="ok",
73
+ data={
74
+ "command_chain": recent_commands,
75
+ "chain_patterns": patterns,
76
+ },
77
+ latency_ms=latency,
78
+ )
79
+ else:
80
+ return AgentResponse(
81
+ request.request_id,
82
+ self.name,
83
+ status="error",
84
+ error=f"Unknown command: {request.command}",
85
+ )
86
+ except Exception as e:
87
+ latency = (time.time() - start_time) * 1000
88
+ return AgentResponse(
89
+ request.request_id,
90
+ self.name,
91
+ status="error",
92
+ error=str(e),
93
+ latency_ms=latency,
94
+ )
95
+
96
+ def _load_patterns(self) -> None:
97
+ """Load workflow patterns from YAML file."""
98
+ if not self.patterns_file.exists():
99
+ self.patterns = []
100
+ return
101
+
102
+ try:
103
+ with open(self.patterns_file, "r", encoding="utf-8") as f:
104
+ data = yaml.safe_load(f)
105
+ self.patterns = data.get("patterns", [])
106
+ except Exception:
107
+ self.patterns = []
108
+
109
+ def _redact_secrets(self, command: str) -> str:
110
+ """Redact secrets from command before storage."""
111
+ config = get_default_config()
112
+ secret_patterns = (
113
+ config.get("v3", {})
114
+ .get("command_history", {})
115
+ .get("secret_patterns", [])
116
+ )
117
+
118
+ redacted = command
119
+ for pattern in secret_patterns:
120
+ try:
121
+ redacted = re.sub(pattern, r"\1***", redacted)
122
+ except Exception:
123
+ pass
124
+
125
+ return redacted
126
+
127
+ async def append_command(
128
+ self, command: str, risk: str, exit_code: Optional[int] = None
129
+ ) -> None:
130
+ """
131
+ Append command to history file (JSONL format).
132
+
133
+ Args:
134
+ command: The command that was executed
135
+ risk: Risk level (SAFE, LOW, MEDIUM, HIGH, CRITICAL)
136
+ exit_code: Command exit code (optional)
137
+ """
138
+ entry = {
139
+ "timestamp": datetime.utcnow().isoformat() + "Z",
140
+ "command": self._redact_secrets(command),
141
+ "risk": risk,
142
+ "exit_code": exit_code,
143
+ "directory": str(Path.cwd()),
144
+ }
145
+
146
+ # Append to JSONL file
147
+ try:
148
+ with open(self.history_file, "a", encoding="utf-8") as f:
149
+ f.write(json.dumps(entry) + "\n")
150
+ except OSError:
151
+ # Fail silently if we can't write to history
152
+ pass
153
+
154
+ async def get_recent_commands(
155
+ self, count: int = 10, minutes: Optional[int] = None
156
+ ) -> List[Dict[str, Any]]:
157
+ """
158
+ Query recent commands from history.
159
+
160
+ Args:
161
+ count: Maximum number of commands to return
162
+ minutes: Optional time window in minutes
163
+
164
+ Returns:
165
+ List of command entries
166
+ """
167
+ if not self.history_file.exists():
168
+ return []
169
+
170
+ commands = []
171
+ cutoff = None
172
+ if minutes:
173
+ cutoff = datetime.utcnow() - timedelta(minutes=minutes)
174
+
175
+ try:
176
+ with open(self.history_file, "r", encoding="utf-8") as f:
177
+ for line in f:
178
+ try:
179
+ entry = json.loads(line.strip())
180
+ timestamp = datetime.fromisoformat(
181
+ entry["timestamp"].replace("Z", "")
182
+ )
183
+
184
+ if cutoff and timestamp < cutoff:
185
+ continue
186
+
187
+ commands.append(entry)
188
+ except (json.JSONDecodeError, KeyError, ValueError):
189
+ continue
190
+ except OSError:
191
+ return []
192
+
193
+ # Return last N commands
194
+ return commands[-count:]
195
+
196
+ async def detect_patterns(
197
+ self, recent_commands: List[Dict[str, Any]], current_command: str
198
+ ) -> Dict[str, Any]:
199
+ """
200
+ Detect workflow patterns in command chain.
201
+
202
+ Args:
203
+ recent_commands: List of recent command entries
204
+ current_command: The command being analyzed
205
+
206
+ Returns:
207
+ Dictionary with detected, broken, and dangerous_escalations lists
208
+ """
209
+ detected_patterns = {
210
+ "detected": [],
211
+ "broken": [],
212
+ "dangerous_escalations": [],
213
+ }
214
+
215
+ for pattern_def in self.patterns:
216
+ match_result = self._match_pattern(
217
+ pattern_def, recent_commands, current_command
218
+ )
219
+
220
+ if match_result["type"] == "expected_workflow":
221
+ detected_patterns["detected"].append(
222
+ {
223
+ "name": pattern_def["name"],
224
+ "description": pattern_def["description"],
225
+ "risk_adjustment": -1, # Lower risk
226
+ }
227
+ )
228
+ elif match_result["type"] == "broken_workflow":
229
+ detected_patterns["broken"].append(
230
+ {
231
+ "name": pattern_def["name"],
232
+ "description": pattern_def["description"],
233
+ "risk_adjustment": +1, # Higher risk
234
+ "missing_step": match_result["missing_step"],
235
+ }
236
+ )
237
+ elif match_result["type"] == "dangerous_escalation":
238
+ detected_patterns["dangerous_escalations"].append(
239
+ {
240
+ "name": pattern_def["name"],
241
+ "description": pattern_def["description"],
242
+ "risk_adjustment": +2, # Much higher risk
243
+ "previous_command": match_result.get("previous_command"),
244
+ }
245
+ )
246
+
247
+ return detected_patterns
248
+
249
+ def _match_pattern(
250
+ self,
251
+ pattern_def: Dict[str, Any],
252
+ history: List[Dict[str, Any]],
253
+ current: str,
254
+ ) -> Dict[str, str]:
255
+ """
256
+ Match a single pattern definition against history.
257
+
258
+ Args:
259
+ pattern_def: Pattern definition from YAML
260
+ history: List of recent commands
261
+ current: Current command being analyzed
262
+
263
+ Returns:
264
+ Dictionary with match type and additional info
265
+ """
266
+ sequence = pattern_def.get("sequence", [])
267
+ if not sequence:
268
+ return {"type": "no_match"}
269
+
270
+ matched_steps = []
271
+
272
+ # Check if current command matches final step
273
+ final_step = sequence[-1]
274
+ try:
275
+ if not re.search(final_step["pattern"], current):
276
+ return {"type": "no_match"}
277
+ except re.error:
278
+ return {"type": "no_match"}
279
+
280
+ # Check if required prior steps are in history
281
+ for step in sequence[:-1]:
282
+ if not step.get("required", False):
283
+ continue
284
+
285
+ found = False
286
+ for entry in reversed(history):
287
+ try:
288
+ if re.search(step["pattern"], entry["command"]):
289
+ matched_steps.append(entry)
290
+ found = True
291
+ break
292
+ except re.error:
293
+ pass
294
+
295
+ if not found:
296
+ return {
297
+ "type": "broken_workflow",
298
+ "missing_step": step["pattern"],
299
+ }
300
+
301
+ # Check for dangerous escalations
302
+ if pattern_def.get("risk_if_detected") == "CRITICAL":
303
+ return {
304
+ "type": "dangerous_escalation",
305
+ "previous_command": (
306
+ matched_steps[0]["command"] if matched_steps else None
307
+ ),
308
+ }
309
+
310
+ return {"type": "expected_workflow"}
311
+
312
+
313
+ # Singleton instance
314
+ _command_chain_agent: Optional[CommandChainAgent] = None
315
+
316
+
317
+ def get_command_chain_agent() -> CommandChainAgent:
318
+ """Get the singleton CommandChainAgent instance."""
319
+ global _command_chain_agent
320
+ if _command_chain_agent is None:
321
+ _command_chain_agent = CommandChainAgent()
322
+ return _command_chain_agent