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.
- shellmate/__init__.py +0 -0
- shellmate/adapters/__init__.py +67 -0
- shellmate/adapters/bash_adapter.sh +7 -0
- shellmate/adapters/ble_adapter.sh +350 -0
- shellmate/adapters/powershell_adapter.ps1 +417 -0
- shellmate/adapters/powershell_predictor.ps1 +97 -0
- shellmate/adapters/zsh_adapter.zsh +8 -0
- shellmate/agents/__init__.py +23 -0
- shellmate/agents/base.py +248 -0
- shellmate/agents/chain_patterns.yaml +98 -0
- shellmate/agents/command_chain.py +322 -0
- shellmate/agents/context_gatherer.py +175 -0
- shellmate/agents/environment.py +363 -0
- shellmate/agents/librarian.py +502 -0
- shellmate/agents/query.py +415 -0
- shellmate/agents/repomix_backend.py +171 -0
- shellmate/agents/watcher.py +266 -0
- shellmate/cache/__init__.py +0 -0
- shellmate/cache/semantic_cache.py +41 -0
- shellmate/cli/__init__.py +0 -0
- shellmate/cli/commands/__init__.py +27 -0
- shellmate/cli/commands/analyze.py +5 -0
- shellmate/cli/commands/config.py +319 -0
- shellmate/cli/commands/configure.py +118 -0
- shellmate/cli/commands/disable.py +11 -0
- shellmate/cli/commands/enable.py +49 -0
- shellmate/cli/commands/init.py +412 -0
- shellmate/cli/commands/install.py +242 -0
- shellmate/cli/commands/migrate.py +147 -0
- shellmate/cli/commands/status.py +31 -0
- shellmate/cli/commands/uninstall.py +78 -0
- shellmate/cli/main.py +114 -0
- shellmate/cli/parser.py +184 -0
- shellmate/cli/version.py +1 -0
- shellmate/completion/__init__.py +4 -0
- shellmate/completion/bash_adapter.py +145 -0
- shellmate/config/__init__.py +33 -0
- shellmate/config/credentials.py +120 -0
- shellmate/config/defaults.py +88 -0
- shellmate/config/manager.py +94 -0
- shellmate/config/paths.py +77 -0
- shellmate/config/secrets.py +66 -0
- shellmate/daemon/__init__.py +0 -0
- shellmate/daemon/lifecycle.py +308 -0
- shellmate/daemon/observability.py +82 -0
- shellmate/daemon/pipeline.py +119 -0
- shellmate/daemon/server.py +605 -0
- shellmate/daemon/session.py +170 -0
- shellmate/daemon/startup_ui.py +43 -0
- shellmate/environment/__init__.py +0 -0
- shellmate/environment/detector.py +130 -0
- shellmate/ipc/__init__.py +0 -0
- shellmate/ipc/client.py +210 -0
- shellmate/ipc/ps_client.py +190 -0
- shellmate/orchestration/__init__.py +3 -0
- shellmate/orchestration/analysis_graph.py +1274 -0
- shellmate/orchestration/state.py +35 -0
- shellmate/parser/__init__.py +0 -0
- shellmate/parser/command_parser.py +111 -0
- shellmate/plugins/__init__.py +0 -0
- shellmate/plugins/base.py +80 -0
- shellmate/plugins/loader.py +41 -0
- shellmate/providers/__init__.py +0 -0
- shellmate/providers/base.py +97 -0
- shellmate/providers/groq_command_guide.py +108 -0
- shellmate/providers/groq_provider.py +127 -0
- shellmate/providers/groq_supervisor.py +78 -0
- shellmate/providers/openrouter_provider.py +149 -0
- shellmate/providers/router.py +272 -0
- shellmate/providers/validate_keys.py +98 -0
- shellmate/providers/xai_provider.py +142 -0
- shellmate/rules/__init__.py +0 -0
- shellmate/rules/catastrophic.yaml +96 -0
- shellmate/rules/catastrophic_matcher.py +167 -0
- shellmate/rules/definitions/__init__.py +0 -0
- shellmate/rules/definitions/file_deletion.yaml +54 -0
- shellmate/rules/definitions/filesystem.yaml +43 -0
- shellmate/rules/definitions/network_execution.yaml +46 -0
- shellmate/rules/definitions/permissions.yaml +54 -0
- shellmate/rules/definitions/process_control.yaml +37 -0
- shellmate/rules/definitions/system_destruction.yaml +67 -0
- shellmate/rules/engine.py +200 -0
- shellmate/sanitizer/__init__.py +0 -0
- shellmate/sanitizer/typo_detector.py +114 -0
- shellmate/ui/__init__.py +10 -0
- shellmate/ui/analysis.py +164 -0
- shellmate/ui/annotations.py +232 -0
- shellmate/ui/banner.py +49 -0
- shellmate/ui/progress.py +126 -0
- shellmate/ui/runtime/__init__.py +15 -0
- shellmate/ui/runtime/console_factory.py +65 -0
- shellmate/ui/runtime/live_scene.py +116 -0
- shellmate/ui/runtime/motion_policy.py +69 -0
- shellmate/ui/runtime/tokens.py +47 -0
- shellmate/ui/scenes/__init__.py +24 -0
- shellmate/ui/scenes/enable.py +316 -0
- shellmate/ui/scenes/help.py +53 -0
- shellmate/ui/scenes/stages.py +128 -0
- shellmate/ui/scenes/status.py +181 -0
- shellmate/ui/status.py +3 -0
- shellmate_cli-3.0.0.dist-info/METADATA +352 -0
- shellmate_cli-3.0.0.dist-info/RECORD +106 -0
- shellmate_cli-3.0.0.dist-info/WHEEL +5 -0
- shellmate_cli-3.0.0.dist-info/entry_points.txt +3 -0
- shellmate_cli-3.0.0.dist-info/licenses/LICENSE +21 -0
- shellmate_cli-3.0.0.dist-info/top_level.txt +1 -0
shellmate/cli/parser.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional, List, Dict
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ExitCode(Enum):
|
|
9
|
+
SUCCESS = 0
|
|
10
|
+
USAGE_ERROR = 2
|
|
11
|
+
TEMP_ERROR = 3
|
|
12
|
+
INTERNAL_ERROR = 4
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Action(Enum):
|
|
16
|
+
ENABLE = "enable"
|
|
17
|
+
DISABLE = "disable"
|
|
18
|
+
STATUS = "status"
|
|
19
|
+
CONFIG = "config"
|
|
20
|
+
INSTALL = "install"
|
|
21
|
+
UNINSTALL = "uninstall"
|
|
22
|
+
HELP = "help"
|
|
23
|
+
VERSION = "version"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ParsedCommand:
|
|
28
|
+
action: Action
|
|
29
|
+
raw_command: Optional[str] = None
|
|
30
|
+
tokens: List[str] = None
|
|
31
|
+
error: Optional[str] = None
|
|
32
|
+
suggestion: Optional[str] = None
|
|
33
|
+
exit_code: ExitCode = ExitCode.SUCCESS
|
|
34
|
+
quiet: bool = False
|
|
35
|
+
no_color: bool = False
|
|
36
|
+
dry_run: bool = False
|
|
37
|
+
verbose: bool = False # NEW Phase 5 - Task 5.1
|
|
38
|
+
|
|
39
|
+
# NEW v3 fields
|
|
40
|
+
queries: List[str] = field(default_factory=list)
|
|
41
|
+
directives: List[str] = field(default_factory=list)
|
|
42
|
+
overrides: Dict[str, str] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
def __post_init__(self):
|
|
45
|
+
if self.tokens is None:
|
|
46
|
+
self.tokens = []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_SUBCOMMANDS = frozenset({
|
|
50
|
+
"enable", "disable", "status", "config",
|
|
51
|
+
"install", "uninstall",
|
|
52
|
+
"-h", "--help", "-v", "--version",
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _extract_bracket_syntax(command: str) -> tuple:
|
|
57
|
+
"""Extract [?...], [!...], [@...] from command string.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
tuple: (cleaned_command, queries, directives, overrides)
|
|
61
|
+
"""
|
|
62
|
+
# Extract queries [?...]
|
|
63
|
+
queries = re.findall(r'\[\?([^\]]+)\]', command)
|
|
64
|
+
|
|
65
|
+
# Extract directives [!...]
|
|
66
|
+
directives = re.findall(r'\[!([^\]]+)\]', command)
|
|
67
|
+
|
|
68
|
+
# Extract overrides [@...]
|
|
69
|
+
overrides_raw = re.findall(r'\[@([^\]]+)\]', command)
|
|
70
|
+
overrides = {}
|
|
71
|
+
for override in overrides_raw:
|
|
72
|
+
if '=' in override:
|
|
73
|
+
key, value = override.split('=', 1)
|
|
74
|
+
overrides[key.strip()] = value.strip()
|
|
75
|
+
|
|
76
|
+
# Remove all brackets from command
|
|
77
|
+
cleaned = re.sub(r'\[[\?!@][^\]]+\]', '', command).strip()
|
|
78
|
+
|
|
79
|
+
return cleaned, queries, directives, overrides
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ShellMateCommandParser:
|
|
83
|
+
def __init__(self, argv: List[str] = None):
|
|
84
|
+
self.argv = argv or sys.argv[1:]
|
|
85
|
+
|
|
86
|
+
def _consume_global_flags(self, args: List[str]):
|
|
87
|
+
"""Parse sm-level flags only before the subcommand.
|
|
88
|
+
|
|
89
|
+
Flags that belong to the analyzed shell command (e.g. pytest -q) must
|
|
90
|
+
not be stripped from argv.
|
|
91
|
+
"""
|
|
92
|
+
quiet = False
|
|
93
|
+
no_color = False
|
|
94
|
+
dry_run = False
|
|
95
|
+
verbose = False # NEW Phase 5 - Task 5.1
|
|
96
|
+
remaining = list(args)
|
|
97
|
+
|
|
98
|
+
while remaining and remaining[0] not in _SUBCOMMANDS:
|
|
99
|
+
flag = remaining.pop(0)
|
|
100
|
+
if flag in ("--quiet", "-q"):
|
|
101
|
+
quiet = True
|
|
102
|
+
elif flag == "--no-color":
|
|
103
|
+
no_color = True
|
|
104
|
+
elif flag == "--dry-run":
|
|
105
|
+
dry_run = True
|
|
106
|
+
elif flag == "--verbose":
|
|
107
|
+
verbose = True
|
|
108
|
+
else:
|
|
109
|
+
remaining.insert(0, flag)
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
return remaining, quiet, no_color, dry_run, verbose
|
|
113
|
+
|
|
114
|
+
def parse(self) -> ParsedCommand:
|
|
115
|
+
# Bare `sm` (and flag-only argv) → usage help (Done When: sm shows command list)
|
|
116
|
+
if not self.argv:
|
|
117
|
+
return ParsedCommand(action=Action.HELP)
|
|
118
|
+
|
|
119
|
+
args, quiet, no_color, dry_run, verbose = self._consume_global_flags(self.argv)
|
|
120
|
+
self._dry_run = dry_run
|
|
121
|
+
|
|
122
|
+
if not args:
|
|
123
|
+
return ParsedCommand(
|
|
124
|
+
action=Action.HELP,
|
|
125
|
+
no_color=no_color,
|
|
126
|
+
dry_run=self._dry_run,
|
|
127
|
+
verbose=verbose,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
first_arg = args[0]
|
|
131
|
+
|
|
132
|
+
if first_arg in ("-h", "--help"):
|
|
133
|
+
return ParsedCommand(action=Action.HELP, no_color=no_color, dry_run=self._dry_run, verbose=verbose)
|
|
134
|
+
|
|
135
|
+
if first_arg in ("-v", "--version"):
|
|
136
|
+
return ParsedCommand(action=Action.VERSION, no_color=no_color, dry_run=self._dry_run, verbose=verbose)
|
|
137
|
+
|
|
138
|
+
if first_arg in ("enable", "disable", "status", "config", "install", "uninstall"):
|
|
139
|
+
parsed = self._parse_simple_command(first_arg, args[1:])
|
|
140
|
+
parsed.no_color = no_color
|
|
141
|
+
parsed.dry_run = self._dry_run
|
|
142
|
+
parsed.verbose = verbose
|
|
143
|
+
return parsed
|
|
144
|
+
|
|
145
|
+
if first_arg.endswith(".py") or "test_" in first_arg or "/test" in first_arg or "\\test" in first_arg or "tests/" in first_arg:
|
|
146
|
+
return ParsedCommand(
|
|
147
|
+
action=Action.HELP,
|
|
148
|
+
error="No command provided",
|
|
149
|
+
suggestion="Run 'sm --help' for usage",
|
|
150
|
+
exit_code=ExitCode.USAGE_ERROR,
|
|
151
|
+
no_color=no_color,
|
|
152
|
+
dry_run=self._dry_run,
|
|
153
|
+
verbose=verbose,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return ParsedCommand(
|
|
157
|
+
action=Action.HELP,
|
|
158
|
+
error=f"Unknown command: {first_arg}",
|
|
159
|
+
suggestion="Run 'sm --help' for usage.",
|
|
160
|
+
exit_code=ExitCode.USAGE_ERROR,
|
|
161
|
+
no_color=no_color,
|
|
162
|
+
dry_run=self._dry_run,
|
|
163
|
+
verbose=verbose,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _parse_simple_command(self, cmd: str, rest: Optional[List[str]] = None) -> ParsedCommand:
|
|
167
|
+
action_map = {
|
|
168
|
+
"enable": Action.ENABLE,
|
|
169
|
+
"disable": Action.DISABLE,
|
|
170
|
+
"status": Action.STATUS,
|
|
171
|
+
"config": Action.CONFIG,
|
|
172
|
+
"install": Action.INSTALL,
|
|
173
|
+
"uninstall": Action.UNINSTALL,
|
|
174
|
+
}
|
|
175
|
+
parsed = ParsedCommand(action=action_map[cmd])
|
|
176
|
+
# Stash remaining flags for commands that accept them
|
|
177
|
+
if rest:
|
|
178
|
+
parsed.tokens = list(rest)
|
|
179
|
+
return parsed
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def parse_argv(argv: List[str] = None) -> ParsedCommand:
|
|
183
|
+
parser = ShellMateCommandParser(argv)
|
|
184
|
+
return parser.parse()
|
shellmate/cli/version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "3.0.0"
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Completion source adapters for autocomplete mode (Phase 4).
|
|
2
|
+
|
|
3
|
+
Works offline / without bash (Windows) via static subcommand maps,
|
|
4
|
+
then bash compgen, then carapace.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shlex
|
|
10
|
+
import subprocess
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
# Shared with ipc/ps_client offline path — keep in sync for ghost-text / Tab.
|
|
14
|
+
_SUBCOMMANDS = {
|
|
15
|
+
"git": [
|
|
16
|
+
"status", "push", "pull", "commit", "checkout", "branch", "log", "diff",
|
|
17
|
+
"add", "merge", "rebase", "reset", "fetch", "clone", "stash", "remote",
|
|
18
|
+
],
|
|
19
|
+
"docker": ["ps", "images", "run", "build", "exec", "stop", "rm", "compose", "logs"],
|
|
20
|
+
"kubectl": ["get", "apply", "delete", "describe", "logs", "exec", "port-forward"],
|
|
21
|
+
"npm": ["install", "run", "test", "build", "start", "ci"],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_FLAG_MAP = {
|
|
25
|
+
"git": ["--force", "--all", "--verbose", "--help", "--amend", "--no-verify"],
|
|
26
|
+
"docker": ["--help", "--version", "--verbose", "--rm", "-it"],
|
|
27
|
+
"kubectl": ["--namespace", "--context", "--kubeconfig", "--help"],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_completions(partial_command: str) -> List[str]:
|
|
32
|
+
"""
|
|
33
|
+
Get completions for a partial command.
|
|
34
|
+
|
|
35
|
+
Order: static map (always available) → bash compgen → carapace.
|
|
36
|
+
Returns full-line suggestions when completing a subcommand (e.g. "git push")
|
|
37
|
+
so ble.sh ghost text can strip the typed prefix.
|
|
38
|
+
"""
|
|
39
|
+
candidates = get_completions_static(partial_command)
|
|
40
|
+
if candidates:
|
|
41
|
+
return candidates
|
|
42
|
+
|
|
43
|
+
candidates = get_completions_bash(partial_command)
|
|
44
|
+
if candidates:
|
|
45
|
+
return candidates
|
|
46
|
+
|
|
47
|
+
return get_completions_carapace(partial_command)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_completions_static(partial_command: str) -> List[str]:
|
|
51
|
+
"""Offline completions from known program maps (Windows-safe)."""
|
|
52
|
+
partial = (partial_command or "").strip()
|
|
53
|
+
if not partial:
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
tokens = shlex.split(partial_command)
|
|
58
|
+
except ValueError:
|
|
59
|
+
tokens = partial_command.split()
|
|
60
|
+
|
|
61
|
+
if not tokens:
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
program = tokens[0]
|
|
65
|
+
ends_with_space = partial_command.endswith(" ")
|
|
66
|
+
current = "" if ends_with_space else tokens[-1]
|
|
67
|
+
|
|
68
|
+
# Flag completion
|
|
69
|
+
if current.startswith("-"):
|
|
70
|
+
flags = get_flag_completions(program, current)
|
|
71
|
+
return flags[:10]
|
|
72
|
+
|
|
73
|
+
# Program name completion (single token)
|
|
74
|
+
if len(tokens) == 1 and not ends_with_space:
|
|
75
|
+
return [p for p in _SUBCOMMANDS if p.startswith(program)][:10]
|
|
76
|
+
|
|
77
|
+
# Subcommand completion: "git pu" → ["git push", "git pull"]
|
|
78
|
+
if program in _SUBCOMMANDS:
|
|
79
|
+
if len(tokens) == 1 and ends_with_space:
|
|
80
|
+
prefix = ""
|
|
81
|
+
elif len(tokens) == 2 and not ends_with_space:
|
|
82
|
+
prefix = tokens[1]
|
|
83
|
+
else:
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
matches = [s for s in _SUBCOMMANDS[program] if s.startswith(prefix)]
|
|
87
|
+
# Full-line form for adapters; also expose bare subcommands for callers
|
|
88
|
+
# that expect pivot-style ["push", "pull"].
|
|
89
|
+
full = [f"{program} {m}" for m in matches]
|
|
90
|
+
return (full or matches)[:10]
|
|
91
|
+
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_completions_bash(partial_command: str) -> List[str]:
|
|
96
|
+
"""Get bash completions using compgen (Linux/WSL/Git Bash)."""
|
|
97
|
+
try:
|
|
98
|
+
tokens = shlex.split(partial_command)
|
|
99
|
+
if not tokens:
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
program = tokens[0]
|
|
103
|
+
current = tokens[-1] if len(tokens) > 1 else program
|
|
104
|
+
|
|
105
|
+
# Escape for safe shell interpolation
|
|
106
|
+
safe_current = current.replace("'", "'\\''")
|
|
107
|
+
result = subprocess.run(
|
|
108
|
+
["bash", "-c", f"compgen -c -- '{safe_current}'"],
|
|
109
|
+
capture_output=True,
|
|
110
|
+
text=True,
|
|
111
|
+
timeout=0.1,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
115
|
+
candidates = [c for c in result.stdout.strip().split("\n") if c]
|
|
116
|
+
# Prefer candidates that extend the typed token
|
|
117
|
+
filtered = [c for c in candidates if c.startswith(current)][:10]
|
|
118
|
+
return filtered
|
|
119
|
+
|
|
120
|
+
return []
|
|
121
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_flag_completions(program: str, partial_flag: str) -> List[str]:
|
|
126
|
+
"""Get flag completions for known programs."""
|
|
127
|
+
flags = _FLAG_MAP.get(program, [])
|
|
128
|
+
return [f for f in flags if f.startswith(partial_flag)]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_completions_carapace(partial_command: str) -> List[str]:
|
|
132
|
+
"""Fallback to carapace if bash-completion unavailable."""
|
|
133
|
+
try:
|
|
134
|
+
result = subprocess.run(
|
|
135
|
+
["carapace", "_carapace", "bash", partial_command],
|
|
136
|
+
capture_output=True,
|
|
137
|
+
text=True,
|
|
138
|
+
timeout=0.2,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
142
|
+
return [c for c in result.stdout.strip().split("\n") if c][:10]
|
|
143
|
+
return []
|
|
144
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, Exception):
|
|
145
|
+
return []
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from shellmate.config.defaults import get_default_config, SHELLMATE_DIR
|
|
2
|
+
from shellmate.config.credentials import (
|
|
3
|
+
CREDENTIAL_KEYS,
|
|
4
|
+
AI_CREDENTIAL_KEYS,
|
|
5
|
+
load_env,
|
|
6
|
+
save_env,
|
|
7
|
+
get_credential_status,
|
|
8
|
+
get_api_key,
|
|
9
|
+
export_script,
|
|
10
|
+
)
|
|
11
|
+
from shellmate.config.manager import get_config, get_config_value, set_config_value, get_config_manager
|
|
12
|
+
from shellmate.config.secrets import redact_secrets, redact_dict, sanitize_for_logging, sanitize_prompt, sanitize_response
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"get_default_config",
|
|
16
|
+
"SHELLMATE_DIR",
|
|
17
|
+
"CREDENTIAL_KEYS",
|
|
18
|
+
"AI_CREDENTIAL_KEYS",
|
|
19
|
+
"load_env",
|
|
20
|
+
"save_env",
|
|
21
|
+
"get_credential_status",
|
|
22
|
+
"get_api_key",
|
|
23
|
+
"export_script",
|
|
24
|
+
"get_config",
|
|
25
|
+
"get_config_value",
|
|
26
|
+
"set_config_value",
|
|
27
|
+
"get_config_manager",
|
|
28
|
+
"redact_secrets",
|
|
29
|
+
"redact_dict",
|
|
30
|
+
"sanitize_for_logging",
|
|
31
|
+
"sanitize_prompt",
|
|
32
|
+
"sanitize_response",
|
|
33
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import stat
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from shellmate.config.paths import CREDENTIALS_FILE, ensure_dirs
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
CREDENTIAL_KEYS = {
|
|
8
|
+
"GROQ_API_KEY": {
|
|
9
|
+
"label": "Groq API Key",
|
|
10
|
+
"description": "For AI risk analysis (get at https://console.groq.com/keys)",
|
|
11
|
+
"doc_url": "https://console.groq.com/keys",
|
|
12
|
+
"required": False,
|
|
13
|
+
"provider": "groq",
|
|
14
|
+
"live_check": True,
|
|
15
|
+
},
|
|
16
|
+
"OPENROUTER_API_KEY": {
|
|
17
|
+
"label": "OpenRouter API Key",
|
|
18
|
+
"description": "For AI remediation suggestions (get at https://openrouter.ai/keys)",
|
|
19
|
+
"doc_url": "https://openrouter.ai/keys",
|
|
20
|
+
"required": False,
|
|
21
|
+
"provider": "openrouter",
|
|
22
|
+
"live_check": True,
|
|
23
|
+
},
|
|
24
|
+
"XAI_API_KEY": {
|
|
25
|
+
"label": "Grok (xAI) API Key",
|
|
26
|
+
"description": "For AI risk analysis and remediation (get at https://console.x.ai/)",
|
|
27
|
+
"doc_url": "https://console.x.ai/",
|
|
28
|
+
"required": False,
|
|
29
|
+
"provider": "xai",
|
|
30
|
+
"live_check": True,
|
|
31
|
+
},
|
|
32
|
+
"CONTEXT7_API_KEY": {
|
|
33
|
+
"label": "Context7 API Key",
|
|
34
|
+
"description": "For documentation lookup (get at https://context7.com)",
|
|
35
|
+
"doc_url": "https://context7.com",
|
|
36
|
+
"required": False,
|
|
37
|
+
"provider": "context7",
|
|
38
|
+
"live_check": False,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# AI providers that can power risk/remediation (excludes optional Context7)
|
|
43
|
+
AI_CREDENTIAL_KEYS = ("GROQ_API_KEY", "OPENROUTER_API_KEY", "XAI_API_KEY")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_env() -> dict:
|
|
47
|
+
if not CREDENTIALS_FILE.exists():
|
|
48
|
+
return {}
|
|
49
|
+
env = {}
|
|
50
|
+
try:
|
|
51
|
+
with open(CREDENTIALS_FILE, "r") as f:
|
|
52
|
+
for line in f:
|
|
53
|
+
line = line.strip()
|
|
54
|
+
if not line or line.startswith("#"):
|
|
55
|
+
continue
|
|
56
|
+
if "=" in line:
|
|
57
|
+
key, _, value = line.partition("=")
|
|
58
|
+
key = key.strip()
|
|
59
|
+
value = value.strip().strip("\"'")
|
|
60
|
+
env[key] = value
|
|
61
|
+
except OSError:
|
|
62
|
+
pass
|
|
63
|
+
return env
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def save_env(env_dict: dict) -> None:
|
|
67
|
+
ensure_dirs()
|
|
68
|
+
with open(CREDENTIALS_FILE, "w") as f:
|
|
69
|
+
f.write("# ShellMate Credentials\n")
|
|
70
|
+
f.write("# Generated by `sm config`\n")
|
|
71
|
+
f.write("# Add your API keys below (one per line, KEY=VALUE format)\n\n")
|
|
72
|
+
for key in CREDENTIAL_KEYS:
|
|
73
|
+
value = env_dict.get(key, "")
|
|
74
|
+
if value:
|
|
75
|
+
f.write(f"{key}={value}\n")
|
|
76
|
+
else:
|
|
77
|
+
f.write(f"# {key}=\n")
|
|
78
|
+
|
|
79
|
+
if os.name != "nt":
|
|
80
|
+
try:
|
|
81
|
+
CREDENTIALS_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_credential_status() -> dict:
|
|
87
|
+
env = load_env()
|
|
88
|
+
result = {}
|
|
89
|
+
for key, info in CREDENTIAL_KEYS.items():
|
|
90
|
+
value = env.get(key, os.environ.get(key, ""))
|
|
91
|
+
result[key] = {
|
|
92
|
+
"configured": bool(value),
|
|
93
|
+
"label": info["label"],
|
|
94
|
+
"masked": _mask_key(value) if value else None,
|
|
95
|
+
}
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_api_key(key_name: str) -> str:
|
|
100
|
+
env = load_env()
|
|
101
|
+
return os.environ.get(key_name, "") or env.get(key_name, "")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _mask_key(key: str) -> str:
|
|
105
|
+
if len(key) <= 8:
|
|
106
|
+
return key[:4] + ("*" * (len(key) - 4))
|
|
107
|
+
return key[:4] + ("*" * (len(key) - 8)) + key[-4:]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def export_script(shell: str = "bash") -> str:
|
|
111
|
+
env = load_env()
|
|
112
|
+
lines = []
|
|
113
|
+
for key in CREDENTIAL_KEYS:
|
|
114
|
+
value = env.get(key)
|
|
115
|
+
if value:
|
|
116
|
+
if shell == "powershell":
|
|
117
|
+
lines.append(f"$env:{key} = '{value}'")
|
|
118
|
+
else:
|
|
119
|
+
lines.append(f"export {key}='{value}'")
|
|
120
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from shellmate.config.paths import get_cache_dir, get_log_dir, get_session_dir, get_state_dir
|
|
3
|
+
|
|
4
|
+
SHELLMATE_DIR = str(get_state_dir())
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_default_config():
|
|
8
|
+
return {
|
|
9
|
+
"daemon": {
|
|
10
|
+
"host": "127.0.0.1",
|
|
11
|
+
"port": 9823,
|
|
12
|
+
"socket_path": str(get_session_dir() / "shellmate.sock"),
|
|
13
|
+
"pipe_name": r"\\.\pipe\shellmate",
|
|
14
|
+
},
|
|
15
|
+
"cache": {
|
|
16
|
+
"enabled": True,
|
|
17
|
+
"backend": "faiss",
|
|
18
|
+
"model": "all-MiniLM-L6-v2",
|
|
19
|
+
"similarity_threshold": 0.92,
|
|
20
|
+
"expiration_days": 30,
|
|
21
|
+
"max_entries": 10000,
|
|
22
|
+
"directory": str(get_cache_dir()),
|
|
23
|
+
},
|
|
24
|
+
"providers": {
|
|
25
|
+
"risk_analysis": {
|
|
26
|
+
"primary": "groq",
|
|
27
|
+
"model": "qwen/qwen3.8-27b",
|
|
28
|
+
"fallback": None,
|
|
29
|
+
},
|
|
30
|
+
"remediation": {
|
|
31
|
+
"primary": "openrouter",
|
|
32
|
+
"model": "gpt-4o-mini",
|
|
33
|
+
"fallbacks": [
|
|
34
|
+
"meta-llama/llama-3.3-70b-instruct",
|
|
35
|
+
"qwen/qwen2.5-72b-instruct",
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
"latency_targets": {
|
|
40
|
+
"cache_hit_ms": 200,
|
|
41
|
+
"rule_match_ms": 50,
|
|
42
|
+
"ai_analysis_ms": 5000,
|
|
43
|
+
},
|
|
44
|
+
"logging": {
|
|
45
|
+
"level": "INFO",
|
|
46
|
+
"directory": str(get_log_dir()),
|
|
47
|
+
"max_bytes": 10485760,
|
|
48
|
+
"backup_count": 5,
|
|
49
|
+
},
|
|
50
|
+
"supervisor": {
|
|
51
|
+
"model": os.environ.get("GROQ_SUPERVISOR_MODEL", "qwen/qwen3.8-27b"),
|
|
52
|
+
"timeout_ms": 500,
|
|
53
|
+
},
|
|
54
|
+
"command_guide": {
|
|
55
|
+
"model": os.environ.get("GROQ_COMMAND_GUIDE_MODEL", "qwen/qwen3.8-27b"),
|
|
56
|
+
"timeout_ms": int(os.environ.get("GROQ_COMMAND_GUIDE_TIMEOUT_MS", "2000")),
|
|
57
|
+
},
|
|
58
|
+
"plugins": {
|
|
59
|
+
"directory": str(get_session_dir() / "plugins"),
|
|
60
|
+
"enabled": True,
|
|
61
|
+
},
|
|
62
|
+
"overrides_file": str(get_session_dir() / "overrides.json"),
|
|
63
|
+
"metrics_file": str(get_session_dir() / "metrics.json"),
|
|
64
|
+
"v3": {
|
|
65
|
+
"environment": {
|
|
66
|
+
"business_hours_start": 9, # 9 AM
|
|
67
|
+
"business_hours_end": 17, # 5 PM
|
|
68
|
+
"timezone": "America/Los_Angeles",
|
|
69
|
+
"cache_ttl_minutes": 5,
|
|
70
|
+
"detection_timeout_ms": 30,
|
|
71
|
+
},
|
|
72
|
+
"command_history": {
|
|
73
|
+
"max_commands": 1000,
|
|
74
|
+
"retention_days": 7,
|
|
75
|
+
"redact_secrets": True,
|
|
76
|
+
"secret_patterns": [
|
|
77
|
+
r"--password[= ](\S+)",
|
|
78
|
+
r"--token[= ](\S+)",
|
|
79
|
+
r"export \w*KEY\w*=",
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
"query_processing": {
|
|
83
|
+
"timeout_ms": 100,
|
|
84
|
+
"cache_enabled": True,
|
|
85
|
+
"max_response_length": 500, # words
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import yaml
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
from shellmate.config.paths import CONFIG_FILE, CREDENTIALS_FILE, ensure_dirs, get_cache_dir, get_log_dir, get_session_dir
|
|
6
|
+
from shellmate.config.defaults import get_default_config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConfigManager:
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self._config: Optional[Dict[str, Any]] = None
|
|
12
|
+
|
|
13
|
+
def load(self) -> Dict[str, Any]:
|
|
14
|
+
if self._config is not None:
|
|
15
|
+
return self._config
|
|
16
|
+
|
|
17
|
+
ensure_dirs()
|
|
18
|
+
config = get_default_config()
|
|
19
|
+
|
|
20
|
+
config["cache"]["directory"] = str(get_cache_dir())
|
|
21
|
+
config["logging"]["directory"] = str(get_log_dir())
|
|
22
|
+
config["session_dir"] = str(get_session_dir())
|
|
23
|
+
|
|
24
|
+
if CONFIG_FILE.exists():
|
|
25
|
+
try:
|
|
26
|
+
with open(CONFIG_FILE, "r") as f:
|
|
27
|
+
user_config = yaml.safe_load(f) or {}
|
|
28
|
+
config = self._deep_merge(config, user_config)
|
|
29
|
+
except (yaml.YAMLError, OSError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
self._config = config
|
|
33
|
+
return config
|
|
34
|
+
|
|
35
|
+
def _deep_merge(self, base: Dict, override: Dict) -> Dict:
|
|
36
|
+
result = base.copy()
|
|
37
|
+
for key, value in override.items():
|
|
38
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
39
|
+
result[key] = self._deep_merge(result[key], value)
|
|
40
|
+
else:
|
|
41
|
+
result[key] = value
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
def save(self, config: Dict[str, Any]) -> None:
|
|
45
|
+
ensure_dirs()
|
|
46
|
+
with open(CONFIG_FILE, "w") as f:
|
|
47
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
48
|
+
self._config = config
|
|
49
|
+
|
|
50
|
+
def get(self, key: str, default=None) -> Any:
|
|
51
|
+
config = self.load()
|
|
52
|
+
keys = key.split(".")
|
|
53
|
+
value = config
|
|
54
|
+
for k in keys:
|
|
55
|
+
if isinstance(value, dict):
|
|
56
|
+
value = value.get(k)
|
|
57
|
+
else:
|
|
58
|
+
return default
|
|
59
|
+
if value is None:
|
|
60
|
+
return default
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
def set(self, key: str, value: Any) -> None:
|
|
64
|
+
config = self.load()
|
|
65
|
+
keys = key.split(".")
|
|
66
|
+
target = config
|
|
67
|
+
for k in keys[:-1]:
|
|
68
|
+
if k not in target:
|
|
69
|
+
target[k] = {}
|
|
70
|
+
target = target[k]
|
|
71
|
+
target[keys[-1]] = value
|
|
72
|
+
self.save(config)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_config_manager = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_config_manager() -> ConfigManager:
|
|
79
|
+
global _config_manager
|
|
80
|
+
if _config_manager is None:
|
|
81
|
+
_config_manager = ConfigManager()
|
|
82
|
+
return _config_manager
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_config() -> Dict[str, Any]:
|
|
86
|
+
return get_config_manager().load()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_config_value(key: str, default=None) -> Any:
|
|
90
|
+
return get_config_manager().get(key, default)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def set_config_value(key: str, value: Any) -> None:
|
|
94
|
+
get_config_manager().set(key, value)
|