mystack-rig 1.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.
- cli/__init__.py +6 -0
- cli/__main__.py +8 -0
- cli/agents.py +575 -0
- cli/config.py +264 -0
- cli/encrypt.py +1182 -0
- cli/git.py +317 -0
- cli/last_env.py +64 -0
- cli/last_session.py +171 -0
- cli/log.py +105 -0
- cli/main.py +1234 -0
- cli/remote_dev.py +5547 -0
- cli/requirements.txt +4 -0
- cli/snapshots.py +627 -0
- cli/storage.py +348 -0
- cli/vault_keys.py +451 -0
- cli/vpn.py +323 -0
- mystack_rig-1.0.0.dist-info/METADATA +285 -0
- mystack_rig-1.0.0.dist-info/RECORD +22 -0
- mystack_rig-1.0.0.dist-info/WHEEL +5 -0
- mystack_rig-1.0.0.dist-info/entry_points.txt +2 -0
- mystack_rig-1.0.0.dist-info/licenses/LICENSE +21 -0
- mystack_rig-1.0.0.dist-info/top_level.txt +1 -0
cli/git.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git module for workspace project management
|
|
3
|
+
|
|
4
|
+
This module provides functionality for:
|
|
5
|
+
- Cloning git repositories on remote development environments
|
|
6
|
+
- Managing git configurations
|
|
7
|
+
- Handling authentication with PAT and SSH keys
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
from typing import Dict, List, Optional, TYPE_CHECKING
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from cli.remote_dev import ProjectConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GitManager:
|
|
20
|
+
"""Manages git operations for workspace projects"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, project_config: Optional["ProjectConfig"] = None):
|
|
23
|
+
self.project_config = project_config
|
|
24
|
+
self.repos_to_clone: List[Dict] = []
|
|
25
|
+
self.ssh_repos: List[Dict] = []
|
|
26
|
+
self.git_config: Dict = {}
|
|
27
|
+
|
|
28
|
+
def load_git_config(self, config_path: Optional[str] = None) -> bool:
|
|
29
|
+
"""Load git configuration from vars.yml"""
|
|
30
|
+
try:
|
|
31
|
+
if self.project_config is None:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
self.project_config.load()
|
|
35
|
+
git_config = self.project_config.get_vars('git')
|
|
36
|
+
|
|
37
|
+
if git_config:
|
|
38
|
+
self.git_config = git_config
|
|
39
|
+
self.repos_to_clone = git_config.get('clone_repos', [])
|
|
40
|
+
self.ssh_repos = git_config.get('ssh_repos', [])
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
return False
|
|
44
|
+
except Exception as e:
|
|
45
|
+
print(f"Warning: Could not load git config: {e}")
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
def get_repos_to_clone(self) -> List[Dict]:
|
|
49
|
+
"""Get list of repositories to clone"""
|
|
50
|
+
return self.repos_to_clone + self.ssh_repos
|
|
51
|
+
|
|
52
|
+
def generate_clone_tasks(self) -> List[Dict]:
|
|
53
|
+
"""Generate Ansible tasks for cloning repositories"""
|
|
54
|
+
tasks = []
|
|
55
|
+
|
|
56
|
+
# Add SSH known hosts setup for public repos
|
|
57
|
+
for repo in self.repos_to_clone:
|
|
58
|
+
repo_url = repo.get('repo', '')
|
|
59
|
+
if repo_url.startswith('https://') or repo_url.startswith('http://'):
|
|
60
|
+
tasks.append({
|
|
61
|
+
'name': f'Add {self._get_hostname(repo_url)} SSH known hosts',
|
|
62
|
+
'shell': f'ssh-keyscan {self._get_hostname(repo_url)} >> ~/.ssh/known_hosts 2>/dev/null || true',
|
|
63
|
+
'changed_when': False,
|
|
64
|
+
'ignore_errors': True
|
|
65
|
+
})
|
|
66
|
+
break # Only add once
|
|
67
|
+
|
|
68
|
+
# Add clone tasks for public repos
|
|
69
|
+
for repo in self.repos_to_clone:
|
|
70
|
+
tasks.append({
|
|
71
|
+
'name': f'Clone {self._get_repo_name(repo.get("repo", ""))}',
|
|
72
|
+
'git': {
|
|
73
|
+
'repo': repo.get('repo'),
|
|
74
|
+
'dest': repo.get('dest'),
|
|
75
|
+
'accept_hostkey': repo.get('accept_hostkey', True),
|
|
76
|
+
'force': repo.get('force', False),
|
|
77
|
+
'version': repo.get('version', 'main')
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
# Add clone tasks for SSH repos
|
|
82
|
+
for repo in self.ssh_repos:
|
|
83
|
+
tasks.append({
|
|
84
|
+
'name': f'Clone {self._get_repo_name(repo.get("repo", ""))} (SSH)',
|
|
85
|
+
'git': {
|
|
86
|
+
'repo': repo.get('repo'),
|
|
87
|
+
'dest': repo.get('dest'),
|
|
88
|
+
'accept_hostkey': repo.get('accept_hostkey', True),
|
|
89
|
+
'force': repo.get('force', False),
|
|
90
|
+
'version': repo.get('version', 'main'),
|
|
91
|
+
'ssh_key': repo.get('ssh_key', '/root/.ssh/id_rsa')
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
return tasks
|
|
96
|
+
|
|
97
|
+
def _get_hostname(self, repo_url: str) -> str:
|
|
98
|
+
"""Extract hostname from repository URL"""
|
|
99
|
+
if repo_url.startswith('https://') or repo_url.startswith('http://'):
|
|
100
|
+
return repo_url.split('/')[2]
|
|
101
|
+
if repo_url.startswith('git@'):
|
|
102
|
+
# git@host:owner/repo.git → host
|
|
103
|
+
return repo_url[len('git@'):].split(':', 1)[0] or 'github.com'
|
|
104
|
+
return 'github.com'
|
|
105
|
+
|
|
106
|
+
def _get_repo_name(self, repo_url: str) -> str:
|
|
107
|
+
"""Extract repository name from URL"""
|
|
108
|
+
# Remove .git suffix
|
|
109
|
+
name = repo_url.rstrip('/')
|
|
110
|
+
if name.endswith('.git'):
|
|
111
|
+
name = name[:-4]
|
|
112
|
+
|
|
113
|
+
# Get last part of URL
|
|
114
|
+
if '/' in name:
|
|
115
|
+
return name.split('/')[-1]
|
|
116
|
+
return name
|
|
117
|
+
|
|
118
|
+
def setup_git_environment(self, project_dir: str) -> bool:
|
|
119
|
+
"""Set up git environment for a project"""
|
|
120
|
+
try:
|
|
121
|
+
# Ensure .rig directory exists
|
|
122
|
+
rig_dir = os.path.join(project_dir, '.rig')
|
|
123
|
+
if not os.path.exists(rig_dir):
|
|
124
|
+
os.makedirs(rig_dir)
|
|
125
|
+
|
|
126
|
+
# Load existing config if available
|
|
127
|
+
if self.project_config is None:
|
|
128
|
+
from cli.remote_dev import ProjectConfig
|
|
129
|
+
self.project_config = ProjectConfig(project_dir)
|
|
130
|
+
|
|
131
|
+
self.project_config.load()
|
|
132
|
+
|
|
133
|
+
# Set default git configuration if not present
|
|
134
|
+
if not self.project_config.get_vars('git'):
|
|
135
|
+
self.project_config.set_vars('git.clone_repos', [])
|
|
136
|
+
self.project_config.set_vars('git.ssh_repos', [])
|
|
137
|
+
|
|
138
|
+
return True
|
|
139
|
+
except Exception as e:
|
|
140
|
+
print(f"Error setting up git environment: {e}")
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
def add_repo(
|
|
144
|
+
self,
|
|
145
|
+
repo_url: str,
|
|
146
|
+
dest: str,
|
|
147
|
+
is_ssh: bool = False,
|
|
148
|
+
password_var: Optional[str] = None,
|
|
149
|
+
) -> bool:
|
|
150
|
+
"""Add a repository to the clone list"""
|
|
151
|
+
try:
|
|
152
|
+
if self.project_config is None:
|
|
153
|
+
from cli.remote_dev import ProjectConfig
|
|
154
|
+
self.project_config = ProjectConfig()
|
|
155
|
+
|
|
156
|
+
self.project_config.load()
|
|
157
|
+
|
|
158
|
+
git_config = self.project_config.get_vars('git') or {}
|
|
159
|
+
clone_repos = git_config.get('clone_repos', [])
|
|
160
|
+
ssh_repos = git_config.get('ssh_repos', [])
|
|
161
|
+
|
|
162
|
+
repo_config = {
|
|
163
|
+
'repo': repo_url,
|
|
164
|
+
'dest': dest,
|
|
165
|
+
'accept_hostkey': True
|
|
166
|
+
}
|
|
167
|
+
if password_var:
|
|
168
|
+
repo_config['password_var'] = password_var
|
|
169
|
+
|
|
170
|
+
if is_ssh:
|
|
171
|
+
repo_config['ssh_key'] = '/root/.ssh/id_rsa'
|
|
172
|
+
ssh_repos.append(repo_config)
|
|
173
|
+
else:
|
|
174
|
+
clone_repos.append(repo_config)
|
|
175
|
+
|
|
176
|
+
git_config['clone_repos'] = clone_repos
|
|
177
|
+
git_config['ssh_repos'] = ssh_repos
|
|
178
|
+
|
|
179
|
+
self.project_config.set_vars('git', git_config)
|
|
180
|
+
self.project_config.save(write_vars=False)
|
|
181
|
+
|
|
182
|
+
# Update internal state
|
|
183
|
+
self.repos_to_clone = clone_repos
|
|
184
|
+
self.ssh_repos = ssh_repos
|
|
185
|
+
|
|
186
|
+
return True
|
|
187
|
+
except Exception as e:
|
|
188
|
+
print(f"Error adding repository: {e}")
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
def set_repo_password_var(self, repo_url: str, password_var: str) -> bool:
|
|
192
|
+
"""Attach or update password_var on an existing clone_repos entry."""
|
|
193
|
+
try:
|
|
194
|
+
if self.project_config is None:
|
|
195
|
+
from cli.remote_dev import ProjectConfig
|
|
196
|
+
self.project_config = ProjectConfig()
|
|
197
|
+
|
|
198
|
+
self.project_config.load()
|
|
199
|
+
git_config = self.project_config.get_vars('git') or {}
|
|
200
|
+
clone_repos = git_config.get('clone_repos', [])
|
|
201
|
+
updated = False
|
|
202
|
+
for item in clone_repos:
|
|
203
|
+
if isinstance(item, dict) and item.get('repo') == repo_url:
|
|
204
|
+
item['password_var'] = password_var
|
|
205
|
+
updated = True
|
|
206
|
+
if not updated:
|
|
207
|
+
return False
|
|
208
|
+
git_config['clone_repos'] = clone_repos
|
|
209
|
+
self.project_config.set_vars('git', git_config)
|
|
210
|
+
self.project_config.save(write_vars=False)
|
|
211
|
+
self.repos_to_clone = clone_repos
|
|
212
|
+
return True
|
|
213
|
+
except Exception as e:
|
|
214
|
+
print(f"Error setting password_var: {e}")
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
def set_repo_dest(self, repo_url: str, dest: str) -> bool:
|
|
218
|
+
"""Update dest on an existing clone_repos / ssh_repos entry."""
|
|
219
|
+
try:
|
|
220
|
+
if self.project_config is None:
|
|
221
|
+
from cli.remote_dev import ProjectConfig
|
|
222
|
+
self.project_config = ProjectConfig()
|
|
223
|
+
|
|
224
|
+
self.project_config.load()
|
|
225
|
+
git_config = self.project_config.get_vars('git') or {}
|
|
226
|
+
updated = False
|
|
227
|
+
for key in ('clone_repos', 'ssh_repos'):
|
|
228
|
+
entries = git_config.get(key, []) or []
|
|
229
|
+
for item in entries:
|
|
230
|
+
if isinstance(item, dict) and item.get('repo') == repo_url:
|
|
231
|
+
if item.get('dest') != dest:
|
|
232
|
+
item['dest'] = dest
|
|
233
|
+
updated = True
|
|
234
|
+
git_config[key] = entries
|
|
235
|
+
if not updated:
|
|
236
|
+
return False
|
|
237
|
+
self.project_config.set_vars('git', git_config)
|
|
238
|
+
self.project_config.save(write_vars=False)
|
|
239
|
+
self.repos_to_clone = git_config.get('clone_repos', [])
|
|
240
|
+
self.ssh_repos = git_config.get('ssh_repos', [])
|
|
241
|
+
return True
|
|
242
|
+
except Exception as e:
|
|
243
|
+
print(f"Error setting repo dest: {e}")
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
def normalize_workspace_dests(self) -> int:
|
|
247
|
+
"""
|
|
248
|
+
Rewrite legacy ``/root/<name>`` clone destinations under ``/workspace``.
|
|
249
|
+
|
|
250
|
+
Returns the number of entries updated.
|
|
251
|
+
"""
|
|
252
|
+
from cli.remote_dev import REMOTE_WORKSPACE_ROOT, remote_workspace_path
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
if self.project_config is None:
|
|
256
|
+
return 0
|
|
257
|
+
self.project_config.load()
|
|
258
|
+
git_config = self.project_config.get_vars('git') or {}
|
|
259
|
+
changed = 0
|
|
260
|
+
for key in ('clone_repos', 'ssh_repos'):
|
|
261
|
+
entries = git_config.get(key, []) or []
|
|
262
|
+
for item in entries:
|
|
263
|
+
if not isinstance(item, dict):
|
|
264
|
+
continue
|
|
265
|
+
dest = str(item.get('dest') or '')
|
|
266
|
+
if not dest:
|
|
267
|
+
continue
|
|
268
|
+
# Legacy home clones → workspace; leave /root/.ssh alone.
|
|
269
|
+
if dest.startswith('/root/') and not dest.startswith('/root/.'):
|
|
270
|
+
name = dest.rstrip('/').split('/')[-1]
|
|
271
|
+
item['dest'] = remote_workspace_path(name)
|
|
272
|
+
changed += 1
|
|
273
|
+
elif not dest.startswith(REMOTE_WORKSPACE_ROOT + '/') and dest != REMOTE_WORKSPACE_ROOT:
|
|
274
|
+
# Keep absolute paths outside /root as-is (custom mounts).
|
|
275
|
+
pass
|
|
276
|
+
git_config[key] = entries
|
|
277
|
+
if changed:
|
|
278
|
+
self.project_config.set_vars('git', git_config)
|
|
279
|
+
self.project_config.save(write_vars=False)
|
|
280
|
+
self.repos_to_clone = git_config.get('clone_repos', [])
|
|
281
|
+
self.ssh_repos = git_config.get('ssh_repos', [])
|
|
282
|
+
return changed
|
|
283
|
+
except Exception as e:
|
|
284
|
+
print(f"Error normalizing workspace dests: {e}")
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
def remove_repo(self, repo_url: str) -> bool:
|
|
288
|
+
"""Remove a repository from the clone list"""
|
|
289
|
+
try:
|
|
290
|
+
if self.project_config is None:
|
|
291
|
+
from cli.remote_dev import ProjectConfig
|
|
292
|
+
self.project_config = ProjectConfig()
|
|
293
|
+
|
|
294
|
+
self.project_config.load()
|
|
295
|
+
|
|
296
|
+
git_config = self.project_config.get_vars('git') or {}
|
|
297
|
+
clone_repos = git_config.get('clone_repos', [])
|
|
298
|
+
ssh_repos = git_config.get('ssh_repos', [])
|
|
299
|
+
|
|
300
|
+
# Remove matching repos
|
|
301
|
+
clone_repos = [r for r in clone_repos if r.get('repo') != repo_url]
|
|
302
|
+
ssh_repos = [r for r in ssh_repos if r.get('repo') != repo_url]
|
|
303
|
+
|
|
304
|
+
git_config['clone_repos'] = clone_repos
|
|
305
|
+
git_config['ssh_repos'] = ssh_repos
|
|
306
|
+
|
|
307
|
+
self.project_config.set_vars('git', git_config)
|
|
308
|
+
self.project_config.save()
|
|
309
|
+
|
|
310
|
+
# Update internal state
|
|
311
|
+
self.repos_to_clone = clone_repos
|
|
312
|
+
self.ssh_repos = ssh_repos
|
|
313
|
+
|
|
314
|
+
return True
|
|
315
|
+
except Exception as e:
|
|
316
|
+
print(f"Error removing repository: {e}")
|
|
317
|
+
return False
|
cli/last_env.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Remember the last handled remote env name under /tmp (cleared on reboot)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
DEFAULT_LAST_ENV_PATH = "/tmp/.rig/last-env"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def remember_last_env(name: str, path: str = DEFAULT_LAST_ENV_PATH) -> None:
|
|
12
|
+
"""Persist *name* so later commands can omit it."""
|
|
13
|
+
if not name:
|
|
14
|
+
return
|
|
15
|
+
directory = os.path.dirname(path)
|
|
16
|
+
if directory:
|
|
17
|
+
os.makedirs(directory, mode=0o700, exist_ok=True)
|
|
18
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
19
|
+
handle.write(f"{name.strip()}\n")
|
|
20
|
+
try:
|
|
21
|
+
os.chmod(path, 0o600)
|
|
22
|
+
except OSError:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_last_env(path: str = DEFAULT_LAST_ENV_PATH) -> Optional[str]:
|
|
27
|
+
"""Return the last remembered environment name, or None."""
|
|
28
|
+
try:
|
|
29
|
+
with open(path, encoding="utf-8") as handle:
|
|
30
|
+
name = handle.read().strip()
|
|
31
|
+
except OSError:
|
|
32
|
+
return None
|
|
33
|
+
return name or None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def clear_last_env(
|
|
37
|
+
name: Optional[str] = None,
|
|
38
|
+
path: str = DEFAULT_LAST_ENV_PATH,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Remove the remembered name.
|
|
42
|
+
|
|
43
|
+
If *name* is given, only clear when it matches the stored value
|
|
44
|
+
(so destroying another env does not forget the current one).
|
|
45
|
+
"""
|
|
46
|
+
if name is not None:
|
|
47
|
+
current = read_last_env(path=path)
|
|
48
|
+
if current is None or current != name:
|
|
49
|
+
return
|
|
50
|
+
try:
|
|
51
|
+
os.remove(path)
|
|
52
|
+
except OSError:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_env_name(
|
|
57
|
+
*candidates: Optional[str],
|
|
58
|
+
path: str = DEFAULT_LAST_ENV_PATH,
|
|
59
|
+
) -> Optional[str]:
|
|
60
|
+
"""First non-empty candidate, else the remembered last env."""
|
|
61
|
+
for value in candidates:
|
|
62
|
+
if value and str(value).strip():
|
|
63
|
+
return str(value).strip()
|
|
64
|
+
return read_last_env(path=path)
|
cli/last_session.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Remember the last coding-agent session id under /tmp (cleared on reboot)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from typing import List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
DEFAULT_SESSION_DIR = "/tmp/.rig/sessions"
|
|
10
|
+
|
|
11
|
+
# Strip common VT sequences / CR so script(1) logs parse cleanly.
|
|
12
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\r")
|
|
13
|
+
|
|
14
|
+
# Prefer explicit resume/session flags from exit banners, then bare ids.
|
|
15
|
+
_SESSION_PATTERNS = (
|
|
16
|
+
re.compile(
|
|
17
|
+
r"--resume(?:=|\s+)([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})",
|
|
18
|
+
re.IGNORECASE,
|
|
19
|
+
),
|
|
20
|
+
re.compile(r"--resume(?:=|\s+)([A-Za-z0-9][A-Za-z0-9_-]{5,})"),
|
|
21
|
+
re.compile(r"(?:--session|-s)(?:=|\s+)(ses_[A-Za-z0-9_-]+)"),
|
|
22
|
+
re.compile(r"\bses_[A-Za-z0-9_-]+\b"),
|
|
23
|
+
re.compile(
|
|
24
|
+
r"\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b",
|
|
25
|
+
re.IGNORECASE,
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _safe_env_name(name: str) -> str:
|
|
31
|
+
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", (name or "").strip())
|
|
32
|
+
return cleaned.strip("-._") or "env"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def session_path(env_name: str, directory: str = DEFAULT_SESSION_DIR) -> str:
|
|
36
|
+
return os.path.join(directory, _safe_env_name(env_name))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def remember_last_session(
|
|
40
|
+
env_name: str,
|
|
41
|
+
session_id: str,
|
|
42
|
+
agent_type: Optional[str] = None,
|
|
43
|
+
directory: str = DEFAULT_SESSION_DIR,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Persist the last session id for *env_name*."""
|
|
46
|
+
if not env_name or not session_id:
|
|
47
|
+
return
|
|
48
|
+
os.makedirs(directory, mode=0o700, exist_ok=True)
|
|
49
|
+
path = session_path(env_name, directory=directory)
|
|
50
|
+
lines = [f"session={session_id.strip()}\n"]
|
|
51
|
+
if agent_type:
|
|
52
|
+
lines.insert(0, f"agent={agent_type.strip()}\n")
|
|
53
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
54
|
+
handle.writelines(lines)
|
|
55
|
+
try:
|
|
56
|
+
os.chmod(path, 0o600)
|
|
57
|
+
os.chmod(directory, 0o700)
|
|
58
|
+
except OSError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_last_session(
|
|
63
|
+
env_name: str,
|
|
64
|
+
directory: str = DEFAULT_SESSION_DIR,
|
|
65
|
+
) -> Optional[Tuple[str, Optional[str]]]:
|
|
66
|
+
"""
|
|
67
|
+
Return ``(session_id, agent_type_or_None)`` for *env_name*, if known.
|
|
68
|
+
"""
|
|
69
|
+
path = session_path(env_name, directory=directory)
|
|
70
|
+
try:
|
|
71
|
+
with open(path, encoding="utf-8") as handle:
|
|
72
|
+
text = handle.read()
|
|
73
|
+
except OSError:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
session_id: Optional[str] = None
|
|
77
|
+
agent_type: Optional[str] = None
|
|
78
|
+
for raw in text.splitlines():
|
|
79
|
+
line = raw.strip()
|
|
80
|
+
if not line or line.startswith("#"):
|
|
81
|
+
continue
|
|
82
|
+
if "=" in line:
|
|
83
|
+
key, value = line.split("=", 1)
|
|
84
|
+
key = key.strip().lower()
|
|
85
|
+
value = value.strip()
|
|
86
|
+
if key in {"session", "id", "session_id"} and value:
|
|
87
|
+
session_id = value
|
|
88
|
+
elif key == "agent" and value:
|
|
89
|
+
agent_type = value
|
|
90
|
+
elif session_id is None:
|
|
91
|
+
session_id = line
|
|
92
|
+
if not session_id:
|
|
93
|
+
return None
|
|
94
|
+
return session_id, agent_type
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def clear_last_session(
|
|
98
|
+
env_name: Optional[str] = None,
|
|
99
|
+
directory: str = DEFAULT_SESSION_DIR,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Remove stored session for one env, or all sessions when *env_name* is None."""
|
|
102
|
+
if env_name is None:
|
|
103
|
+
try:
|
|
104
|
+
for name in os.listdir(directory):
|
|
105
|
+
try:
|
|
106
|
+
os.remove(os.path.join(directory, name))
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
except OSError:
|
|
110
|
+
pass
|
|
111
|
+
return
|
|
112
|
+
try:
|
|
113
|
+
os.remove(session_path(env_name, directory=directory))
|
|
114
|
+
except OSError:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def strip_ansi(text: str) -> str:
|
|
119
|
+
return _ANSI_RE.sub("", text or "")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_session_id(text: str) -> Optional[str]:
|
|
123
|
+
"""Extract the most likely session / thread id from agent exit output."""
|
|
124
|
+
cleaned = strip_ansi(text)
|
|
125
|
+
if not cleaned.strip():
|
|
126
|
+
return None
|
|
127
|
+
last: Optional[str] = None
|
|
128
|
+
for pattern in _SESSION_PATTERNS:
|
|
129
|
+
for match in pattern.finditer(cleaned):
|
|
130
|
+
value = match.group(1) if match.lastindex else match.group(0)
|
|
131
|
+
if value:
|
|
132
|
+
last = value
|
|
133
|
+
return last
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def extract_session_from_agent_args(agent_args: Optional[List[str]]) -> Optional[str]:
|
|
137
|
+
"""Return a session id explicitly present in forwarded agent argv."""
|
|
138
|
+
args = list(agent_args or [])
|
|
139
|
+
for index, token in enumerate(args):
|
|
140
|
+
if token in {"--session", "-s", "--resume"} and index + 1 < len(args):
|
|
141
|
+
value = args[index + 1]
|
|
142
|
+
if value and not str(value).startswith("-"):
|
|
143
|
+
return str(value)
|
|
144
|
+
for prefix in ("--session=", "--resume=", "-s="):
|
|
145
|
+
if token.startswith(prefix) and len(token) > len(prefix):
|
|
146
|
+
return token[len(prefix):]
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def agent_args_request_session(agent_args: Optional[List[str]]) -> bool:
|
|
151
|
+
"""True when argv already asks to continue/resume/select a session."""
|
|
152
|
+
args = list(agent_args or [])
|
|
153
|
+
for index, token in enumerate(args):
|
|
154
|
+
if token in {"--continue", "-c", "--resume", "--session", "-s"}:
|
|
155
|
+
return True
|
|
156
|
+
if token.startswith("--resume=") or token.startswith("--session=") or token.startswith("-s="):
|
|
157
|
+
return True
|
|
158
|
+
# bare ``--resume`` with optional id already covered above
|
|
159
|
+
if token == "--" and index + 1 < len(args):
|
|
160
|
+
# remainder after -- still scanned in loop
|
|
161
|
+
continue
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def resume_args_for_agent(agent_type: Optional[str], session_id: str) -> List[str]:
|
|
166
|
+
"""Build CLI flags to reopen *session_id* for the given agent."""
|
|
167
|
+
agent = (agent_type or "opencode").strip().lower()
|
|
168
|
+
if agent in {"opencode", "open-code"}:
|
|
169
|
+
return ["--session", session_id]
|
|
170
|
+
# Claude Code and Cursor CLI both use --resume <id>
|
|
171
|
+
return ["--resume", session_id]
|
cli/log.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Shared CLI logging for rig.
|
|
2
|
+
|
|
3
|
+
Default level is INFO: lifecycle lines only. Playbook task output is shown
|
|
4
|
+
when a task fails, or when verbosity is raised.
|
|
5
|
+
|
|
6
|
+
``rig -v`` streams each playbook task and turns on DEBUG. ``-vv``, ``-vvv``,
|
|
7
|
+
and ``-vvvv`` pass that same verbosity through to ansible-playbook.
|
|
8
|
+
``rig -q`` / ``--quiet`` shows warnings and errors only.
|
|
9
|
+
|
|
10
|
+
Format (stderr): INFO is message-only; DEBUG/WARNING/ERROR keep a level prefix.
|
|
11
|
+
Stdout stays free for command output (encrypt ciphertext, decrypt plaintext, etc.).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
LOGGER_NAME = "rig"
|
|
21
|
+
_CONFIGURED = False
|
|
22
|
+
_VERBOSITY = 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _LevelFormatter(logging.Formatter):
|
|
26
|
+
"""INFO: bare message. Other levels: ``LEVEL message``."""
|
|
27
|
+
|
|
28
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
29
|
+
message = record.getMessage()
|
|
30
|
+
if record.levelno == logging.INFO:
|
|
31
|
+
return message
|
|
32
|
+
return f"{record.levelname.upper():<5} {message}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_logger(name: Optional[str] = None) -> logging.Logger:
|
|
36
|
+
"""Return the rig logger (or a child under ``rig.``)."""
|
|
37
|
+
if name and name != LOGGER_NAME:
|
|
38
|
+
if not name.startswith(f"{LOGGER_NAME}."):
|
|
39
|
+
name = f"{LOGGER_NAME}.{name}"
|
|
40
|
+
return logging.getLogger(name)
|
|
41
|
+
return logging.getLogger(LOGGER_NAME)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def verbosity() -> int:
|
|
45
|
+
"""How much detail the current command asked for. ``0`` is the default."""
|
|
46
|
+
return _VERBOSITY
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def configure_logging(
|
|
50
|
+
*,
|
|
51
|
+
verbose: bool = False,
|
|
52
|
+
quiet: bool = False,
|
|
53
|
+
level: Optional[int] = None,
|
|
54
|
+
verbosity: Optional[int] = None,
|
|
55
|
+
) -> logging.Logger:
|
|
56
|
+
"""
|
|
57
|
+
Configure the root ``rig`` logger once (or reconfigure on subsequent calls).
|
|
58
|
+
|
|
59
|
+
``verbosity`` counts ``-v`` flags. Precedence for the level: explicit
|
|
60
|
+
``level`` > any verbosity or ``verbose`` > ``quiet`` > INFO.
|
|
61
|
+
"""
|
|
62
|
+
global _CONFIGURED, _VERBOSITY
|
|
63
|
+
if verbosity is None:
|
|
64
|
+
verbosity = 1 if verbose else 0
|
|
65
|
+
_VERBOSITY = max(0, int(verbosity))
|
|
66
|
+
if level is None:
|
|
67
|
+
if _VERBOSITY > 0 or verbose:
|
|
68
|
+
level = logging.DEBUG
|
|
69
|
+
elif quiet:
|
|
70
|
+
level = logging.WARNING
|
|
71
|
+
else:
|
|
72
|
+
level = logging.INFO
|
|
73
|
+
|
|
74
|
+
logger = get_logger()
|
|
75
|
+
logger.handlers.clear()
|
|
76
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
77
|
+
handler.setFormatter(_LevelFormatter())
|
|
78
|
+
logger.addHandler(handler)
|
|
79
|
+
logger.setLevel(level)
|
|
80
|
+
logger.propagate = False
|
|
81
|
+
_CONFIGURED = True
|
|
82
|
+
return logger
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def ensure_logging() -> logging.Logger:
|
|
86
|
+
"""Configure with defaults if nothing has set logging up yet."""
|
|
87
|
+
if not _CONFIGURED:
|
|
88
|
+
return configure_logging()
|
|
89
|
+
return get_logger()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def debug(msg: str, *args, **kwargs) -> None:
|
|
93
|
+
ensure_logging().debug(msg, *args, **kwargs)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def info(msg: str, *args, **kwargs) -> None:
|
|
97
|
+
ensure_logging().info(msg, *args, **kwargs)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def warning(msg: str, *args, **kwargs) -> None:
|
|
101
|
+
ensure_logging().warning(msg, *args, **kwargs)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def error(msg: str, *args, **kwargs) -> None:
|
|
105
|
+
ensure_logging().error(msg, *args, **kwargs)
|