researchloop 0.1.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 (63) hide show
  1. researchloop/__init__.py +1 -0
  2. researchloop/__main__.py +3 -0
  3. researchloop/cli.py +1138 -0
  4. researchloop/clusters/__init__.py +4 -0
  5. researchloop/clusters/monitor.py +199 -0
  6. researchloop/clusters/ssh.py +183 -0
  7. researchloop/comms/__init__.py +0 -0
  8. researchloop/comms/base.py +34 -0
  9. researchloop/comms/conversation.py +465 -0
  10. researchloop/comms/ntfy.py +95 -0
  11. researchloop/comms/router.py +71 -0
  12. researchloop/comms/slack.py +188 -0
  13. researchloop/core/__init__.py +0 -0
  14. researchloop/core/auth.py +78 -0
  15. researchloop/core/config.py +328 -0
  16. researchloop/core/credentials.py +38 -0
  17. researchloop/core/models.py +119 -0
  18. researchloop/core/orchestrator.py +910 -0
  19. researchloop/dashboard/__init__.py +0 -0
  20. researchloop/dashboard/app.py +15 -0
  21. researchloop/dashboard/auth.py +60 -0
  22. researchloop/dashboard/routes.py +912 -0
  23. researchloop/dashboard/templates/base.html +84 -0
  24. researchloop/dashboard/templates/login.html +12 -0
  25. researchloop/dashboard/templates/loop_detail.html +58 -0
  26. researchloop/dashboard/templates/loops.html +61 -0
  27. researchloop/dashboard/templates/setup.html +14 -0
  28. researchloop/dashboard/templates/sprint_detail.html +109 -0
  29. researchloop/dashboard/templates/sprints.html +48 -0
  30. researchloop/dashboard/templates/studies.html +18 -0
  31. researchloop/dashboard/templates/study_detail.html +64 -0
  32. researchloop/db/__init__.py +5 -0
  33. researchloop/db/database.py +86 -0
  34. researchloop/db/migrations.py +172 -0
  35. researchloop/db/queries.py +351 -0
  36. researchloop/runner/__init__.py +1 -0
  37. researchloop/runner/claude.py +169 -0
  38. researchloop/runner/job_templates/sge.sh.j2 +319 -0
  39. researchloop/runner/job_templates/slurm.sh.j2 +336 -0
  40. researchloop/runner/main.py +156 -0
  41. researchloop/runner/pipeline.py +272 -0
  42. researchloop/runner/templates/fix_issues.md.j2 +11 -0
  43. researchloop/runner/templates/idea_generator.md.j2 +16 -0
  44. researchloop/runner/templates/red_team.md.j2 +15 -0
  45. researchloop/runner/templates/report.md.j2 +31 -0
  46. researchloop/runner/templates/research_sprint.md.j2 +51 -0
  47. researchloop/runner/templates/summarizer.md.j2 +7 -0
  48. researchloop/runner/upload.py +153 -0
  49. researchloop/schedulers/__init__.py +11 -0
  50. researchloop/schedulers/base.py +43 -0
  51. researchloop/schedulers/local.py +188 -0
  52. researchloop/schedulers/sge.py +163 -0
  53. researchloop/schedulers/slurm.py +179 -0
  54. researchloop/sprints/__init__.py +0 -0
  55. researchloop/sprints/auto_loop.py +458 -0
  56. researchloop/sprints/manager.py +750 -0
  57. researchloop/studies/__init__.py +0 -0
  58. researchloop/studies/manager.py +102 -0
  59. researchloop-0.1.0.dist-info/METADATA +596 -0
  60. researchloop-0.1.0.dist-info/RECORD +63 -0
  61. researchloop-0.1.0.dist-info/WHEEL +4 -0
  62. researchloop-0.1.0.dist-info/entry_points.txt +3 -0
  63. researchloop-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,188 @@
1
+ """Slack integration -- Events API webhook handler and notifier."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import logging
8
+ import time
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from researchloop.comms.base import BaseNotifier
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _SLACK_API = "https://slack.com/api"
18
+
19
+
20
+ class SlackNotifier(BaseNotifier):
21
+ """Sends notifications to Slack channels/threads."""
22
+
23
+ def __init__(
24
+ self,
25
+ bot_token: str,
26
+ channel_id: str | None = None,
27
+ dashboard_url: str | None = None,
28
+ conversation_manager: Any = None,
29
+ ) -> None:
30
+ self.bot_token = bot_token
31
+ self.channel_id = channel_id
32
+ self.dashboard_url = dashboard_url
33
+ self._cm = conversation_manager
34
+
35
+ async def _post_message(
36
+ self,
37
+ text: str,
38
+ channel: str | None = None,
39
+ thread_ts: str | None = None,
40
+ ) -> dict[str, Any]:
41
+ ch = channel or self.channel_id
42
+ if not ch:
43
+ logger.warning("No Slack channel configured")
44
+ return {}
45
+ payload: dict[str, Any] = {
46
+ "channel": ch,
47
+ "text": text,
48
+ }
49
+ if thread_ts:
50
+ payload["thread_ts"] = thread_ts
51
+ async with httpx.AsyncClient() as client:
52
+ resp = await client.post(
53
+ f"{_SLACK_API}/chat.postMessage",
54
+ headers={
55
+ "Authorization": f"Bearer {self.bot_token}",
56
+ "Content-Type": "application/json",
57
+ },
58
+ json=payload,
59
+ timeout=10.0,
60
+ )
61
+ data = resp.json()
62
+ if not data.get("ok"):
63
+ logger.error("Slack API error: %s", data.get("error"))
64
+ return data
65
+
66
+ async def _upload_file(
67
+ self,
68
+ filepath: str,
69
+ filename: str,
70
+ channel: str | None = None,
71
+ initial_comment: str = "",
72
+ ) -> dict[str, Any]:
73
+ """Upload a file to a Slack channel."""
74
+ ch = channel or self.channel_id
75
+ if not ch:
76
+ return {}
77
+ try:
78
+ with open(filepath, "rb") as f:
79
+ async with httpx.AsyncClient() as client:
80
+ resp = await client.post(
81
+ f"{_SLACK_API}/files.upload",
82
+ headers={
83
+ "Authorization": (f"Bearer {self.bot_token}"),
84
+ },
85
+ data={
86
+ "channels": ch,
87
+ "filename": filename,
88
+ "initial_comment": initial_comment,
89
+ },
90
+ files={"file": (filename, f)},
91
+ timeout=30.0,
92
+ )
93
+ data = resp.json()
94
+ if not data.get("ok"):
95
+ logger.error(
96
+ "Slack file upload error: %s",
97
+ data.get("error"),
98
+ )
99
+ return data
100
+ except Exception:
101
+ logger.exception("Failed to upload file to Slack")
102
+ return {}
103
+
104
+ def _link(self, sprint_id: str) -> str:
105
+ if self.dashboard_url:
106
+ url = self.dashboard_url.rstrip("/")
107
+ return f"<{url}/dashboard/sprints/{sprint_id}|{sprint_id}>"
108
+ return sprint_id
109
+
110
+ async def notify_sprint_started(
111
+ self,
112
+ sprint_id: str,
113
+ study_name: str,
114
+ idea: str,
115
+ ) -> None:
116
+ link = self._link(sprint_id)
117
+ idea_trunc = idea[:300] + "…" if len(idea) > 300 else idea
118
+ msg = (
119
+ f":rocket: Sprint *{link}* started\n"
120
+ f"*Study:* {study_name}\n"
121
+ f"*Idea:* {idea_trunc}"
122
+ )
123
+ resp = await self._post_message(msg)
124
+ ts = resp.get("ts", "")
125
+ if ts and self._cm:
126
+ await self._cm.store_bot_message(ts, msg)
127
+
128
+ async def notify_sprint_completed(
129
+ self,
130
+ sprint_id: str,
131
+ study_name: str,
132
+ summary: str,
133
+ pdf_path: str | None = None,
134
+ ) -> None:
135
+ link = self._link(sprint_id)
136
+ summary_trunc = summary[:500] + "…" if len(summary) > 500 else summary
137
+ msg = (
138
+ f":white_check_mark: Sprint *{link}* completed\n"
139
+ f"*Study:* {study_name}\n"
140
+ f"*Summary:* {summary_trunc}"
141
+ )
142
+ resp = await self._post_message(msg)
143
+ # Store the notification for thread context.
144
+ ts = resp.get("ts", "")
145
+ if ts and self._cm:
146
+ await self._cm.store_bot_message(ts, msg)
147
+ if pdf_path:
148
+ await self._upload_file(
149
+ pdf_path,
150
+ f"{sprint_id}-report.pdf",
151
+ initial_comment=f"Report for sprint {sprint_id}",
152
+ )
153
+
154
+ async def notify_sprint_failed(
155
+ self,
156
+ sprint_id: str,
157
+ study_name: str,
158
+ error: str,
159
+ ) -> None:
160
+ link = self._link(sprint_id)
161
+ msg = (
162
+ f":x: Sprint *{link}* failed\n*Study:* {study_name}\n*Error:* {error[:500]}"
163
+ )
164
+ resp = await self._post_message(msg)
165
+ ts = resp.get("ts", "")
166
+ if ts and self._cm:
167
+ await self._cm.store_bot_message(ts, msg)
168
+
169
+
170
+ def verify_slack_signature(
171
+ signing_secret: str,
172
+ timestamp: str,
173
+ body: bytes,
174
+ signature: str,
175
+ ) -> bool:
176
+ """Verify a Slack request signature."""
177
+ if abs(time.time() - int(timestamp)) > 60 * 5:
178
+ return False
179
+ basestring = f"v0:{timestamp}:{body.decode('utf-8')}"
180
+ computed = (
181
+ "v0="
182
+ + hmac.new(
183
+ signing_secret.encode(),
184
+ basestring.encode(),
185
+ hashlib.sha256,
186
+ ).hexdigest()
187
+ )
188
+ return hmac.compare_digest(computed, signature)
File without changes
@@ -0,0 +1,78 @@
1
+ """Claude CLI authentication helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import shutil
8
+ import subprocess
9
+
10
+
11
+ def check_claude_auth() -> tuple[bool, str]:
12
+ """Check whether the Claude CLI is authenticated.
13
+
14
+ Uses ``claude auth status`` which returns instant JSON.
15
+
16
+ Returns ``(ok, detail)`` where *ok* is True if authenticated
17
+ and *detail* is a human-readable status string.
18
+ """
19
+ claude = shutil.which("claude")
20
+ if claude is None:
21
+ return False, "Claude CLI not found in PATH"
22
+
23
+ try:
24
+ result = subprocess.run(
25
+ [claude, "auth", "status"],
26
+ capture_output=True,
27
+ text=True,
28
+ timeout=10,
29
+ )
30
+ if result.returncode == 0:
31
+ try:
32
+ data = json.loads(result.stdout)
33
+ if data.get("loggedIn"):
34
+ email = data.get("email", "")
35
+ sub = data.get("subscriptionType", "")
36
+ detail = f"{email} ({sub})" if email else "OK"
37
+ return True, detail
38
+ return False, "Not logged in"
39
+ except json.JSONDecodeError:
40
+ return True, "Authenticated"
41
+ return False, "Not logged in — run: researchloop login"
42
+ except subprocess.TimeoutExpired:
43
+ return False, "Claude CLI timed out"
44
+ except Exception as exc:
45
+ return False, f"Error checking auth: {exc}"
46
+
47
+
48
+ async def check_claude_auth_async() -> tuple[bool, str]:
49
+ """Async version of :func:`check_claude_auth`."""
50
+ claude = shutil.which("claude")
51
+ if claude is None:
52
+ return False, "Claude CLI not found in PATH"
53
+
54
+ try:
55
+ proc = await asyncio.create_subprocess_exec(
56
+ claude,
57
+ "auth",
58
+ "status",
59
+ stdout=asyncio.subprocess.PIPE,
60
+ stderr=asyncio.subprocess.PIPE,
61
+ )
62
+ stdout, _stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
63
+ if proc.returncode == 0:
64
+ try:
65
+ data = json.loads(stdout.decode("utf-8"))
66
+ if data.get("loggedIn"):
67
+ email = data.get("email", "")
68
+ sub = data.get("subscriptionType", "")
69
+ detail = f"{email} ({sub})" if email else "OK"
70
+ return True, detail
71
+ return False, "Not logged in"
72
+ except json.JSONDecodeError:
73
+ return True, "Authenticated"
74
+ return False, "Not logged in"
75
+ except asyncio.TimeoutError:
76
+ return False, "Claude CLI timed out"
77
+ except Exception as exc:
78
+ return False, f"Error checking auth: {exc}"
@@ -0,0 +1,328 @@
1
+ """Configuration loading for researchloop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ if sys.version_info >= (3, 11):
11
+ import tomllib
12
+ else:
13
+ try:
14
+ import tomli as tomllib # type: ignore[import-not-found]
15
+ except ImportError as exc:
16
+ raise ImportError(
17
+ "tomli is required for Python < 3.11: pip install tomli"
18
+ ) from exc
19
+
20
+ CONFIG_FILENAMES = ["researchloop.toml"]
21
+ CONFIG_SEARCH_PATHS = [
22
+ Path.cwd(),
23
+ Path.home() / ".config" / "researchloop",
24
+ ]
25
+
26
+
27
+ @dataclass
28
+ class ClusterConfig:
29
+ """Configuration for a compute cluster."""
30
+
31
+ name: str
32
+ host: str
33
+ port: int = 22
34
+ user: str = ""
35
+ key_path: str = ""
36
+ scheduler_type: str = "slurm" # "slurm", "sge", "local"
37
+ working_dir: str = ""
38
+ max_concurrent_jobs: int = 4
39
+ environment: dict[str, str] = field(default_factory=dict)
40
+ job_options: dict[str, str] = field(default_factory=dict)
41
+ claude_command: str = "claude --dangerously-skip-permissions"
42
+ context: str = ""
43
+ context_paths: list[str] = field(default_factory=list)
44
+
45
+
46
+ @dataclass
47
+ class StudyConfig:
48
+ """Configuration for a research study."""
49
+
50
+ name: str
51
+ cluster: str
52
+ claude_md_path: str = ""
53
+ context: str = ""
54
+ sprints_dir: str = ""
55
+ claude_command: str = ""
56
+ job_options: dict[str, str] = field(default_factory=dict)
57
+ max_sprint_duration_hours: int = 8
58
+ red_team_max_rounds: int = 3
59
+ description: str = ""
60
+ allow_loop: bool = True
61
+
62
+
63
+ @dataclass
64
+ class SlackConfig:
65
+ """Slack notification settings."""
66
+
67
+ bot_token: str = ""
68
+ signing_secret: str = ""
69
+ channel_id: str | None = None
70
+ allowed_user_ids: list[str] = field(default_factory=list)
71
+ restrict_to_channel: bool = False
72
+
73
+
74
+ @dataclass
75
+ class NtfyConfig:
76
+ """Ntfy notification settings."""
77
+
78
+ url: str = "https://ntfy.sh"
79
+ topic: str = ""
80
+
81
+
82
+ @dataclass
83
+ class DashboardConfig:
84
+ """Dashboard web UI settings."""
85
+
86
+ enabled: bool = True
87
+ host: str = "0.0.0.0"
88
+ port: int = 8080
89
+ password_hash: str | None = None
90
+
91
+
92
+ @dataclass
93
+ class Config:
94
+ """Top-level researchloop configuration."""
95
+
96
+ studies: list[StudyConfig] = field(default_factory=list)
97
+ clusters: list[ClusterConfig] = field(default_factory=list)
98
+ slack: SlackConfig | None = None
99
+ ntfy: NtfyConfig | None = None
100
+ dashboard: DashboardConfig = field(default_factory=DashboardConfig)
101
+ claude_command: str = ""
102
+ context: str = ""
103
+ context_paths: list[str] = field(default_factory=list)
104
+ db_path: str = "researchloop.db"
105
+ artifact_dir: str = "artifacts"
106
+ shared_secret: str | None = None
107
+ orchestrator_url: str | None = None
108
+
109
+
110
+ def _parse_cluster(data: dict) -> ClusterConfig:
111
+ ctx = data.get("context_paths", [])
112
+ if isinstance(ctx, str):
113
+ ctx = [ctx]
114
+ return ClusterConfig(
115
+ name=data["name"],
116
+ host=data.get("host", ""),
117
+ port=data.get("port", 22),
118
+ user=data.get("user", ""),
119
+ key_path=data.get("key_path", ""),
120
+ scheduler_type=data.get("scheduler_type", "slurm"),
121
+ working_dir=data.get("working_dir", ""),
122
+ max_concurrent_jobs=data.get("max_concurrent_jobs", 4),
123
+ environment=data.get("environment", {}),
124
+ job_options=data.get("job_options", {}),
125
+ claude_command=data.get(
126
+ "claude_command",
127
+ "claude --dangerously-skip-permissions",
128
+ ),
129
+ context=data.get("context", ""),
130
+ context_paths=ctx,
131
+ )
132
+
133
+
134
+ def _parse_study(data: dict) -> StudyConfig:
135
+ return StudyConfig(
136
+ name=data["name"],
137
+ cluster=data.get("cluster", ""),
138
+ claude_md_path=data.get("claude_md_path", ""),
139
+ context=data.get("context", ""),
140
+ sprints_dir=data.get("sprints_dir", ""),
141
+ max_sprint_duration_hours=data.get("max_sprint_duration_hours", 8),
142
+ red_team_max_rounds=data.get("red_team_max_rounds", 3),
143
+ claude_command=data.get("claude_command", ""),
144
+ job_options=data.get("job_options", {}),
145
+ description=data.get("description", ""),
146
+ allow_loop=data.get("allow_loop", True),
147
+ )
148
+
149
+
150
+ def _parse_config(data: dict) -> Config:
151
+ clusters = [_parse_cluster(c) for c in data.get("cluster", [])]
152
+ studies = [_parse_study(s) for s in data.get("study", [])]
153
+
154
+ slack = None
155
+ if "slack" in data:
156
+ s = data["slack"]
157
+ allowed = s.get("allowed_user_ids", [])
158
+ if isinstance(allowed, str):
159
+ allowed = [allowed]
160
+ slack = SlackConfig(
161
+ bot_token=s.get("bot_token", ""),
162
+ signing_secret=s.get("signing_secret", ""),
163
+ channel_id=s.get("channel_id"),
164
+ allowed_user_ids=allowed,
165
+ restrict_to_channel=s.get("restrict_to_channel", False),
166
+ )
167
+
168
+ ntfy = None
169
+ if "ntfy" in data:
170
+ n = data["ntfy"]
171
+ ntfy = NtfyConfig(
172
+ url=n.get("url", "https://ntfy.sh"),
173
+ topic=n.get("topic", ""),
174
+ )
175
+
176
+ dashboard_data = data.get("dashboard", {})
177
+ dashboard = DashboardConfig(
178
+ enabled=dashboard_data.get("enabled", True),
179
+ host=dashboard_data.get("host", "0.0.0.0"),
180
+ port=dashboard_data.get("port", 8080),
181
+ password_hash=dashboard_data.get("password_hash"),
182
+ )
183
+
184
+ global_ctx = data.get("context_paths", [])
185
+ if isinstance(global_ctx, str):
186
+ global_ctx = [global_ctx]
187
+
188
+ return Config(
189
+ studies=studies,
190
+ clusters=clusters,
191
+ slack=slack,
192
+ ntfy=ntfy,
193
+ dashboard=dashboard,
194
+ claude_command=data.get("claude_command", ""),
195
+ context=data.get("context", ""),
196
+ context_paths=global_ctx,
197
+ db_path=data.get("db_path", "researchloop.db"),
198
+ artifact_dir=data.get("artifact_dir", "artifacts"),
199
+ shared_secret=data.get("shared_secret"),
200
+ orchestrator_url=data.get("orchestrator_url"),
201
+ )
202
+
203
+
204
+ def load_config(path: str | None = None) -> Config:
205
+ """Load configuration from a researchloop.toml file.
206
+
207
+ Search order:
208
+ 1. Explicit ``path`` argument.
209
+ 2. ``researchloop.toml`` in the current working directory.
210
+ 3. ``~/.config/researchloop/researchloop.toml``.
211
+
212
+ Returns a ``Config`` instance. Raises ``FileNotFoundError`` if no
213
+ configuration file can be located.
214
+ """
215
+ if path is not None:
216
+ config_path = Path(path)
217
+ if not config_path.exists():
218
+ raise FileNotFoundError(f"Config file not found: {path}")
219
+ else:
220
+ config_path = None
221
+ for search_dir in CONFIG_SEARCH_PATHS:
222
+ for filename in CONFIG_FILENAMES:
223
+ candidate = search_dir / filename
224
+ if candidate.exists():
225
+ config_path = candidate
226
+ break
227
+ if config_path is not None:
228
+ break
229
+
230
+ if config_path is None:
231
+ raise FileNotFoundError(
232
+ "No researchloop.toml found. Searched: "
233
+ + ", ".join(str(p) for p in CONFIG_SEARCH_PATHS)
234
+ )
235
+
236
+ with open(config_path, "rb") as f:
237
+ data = tomllib.load(f)
238
+
239
+ config = _parse_config(data)
240
+ _apply_env_overrides(config)
241
+ return config
242
+
243
+
244
+ # ------------------------------------------------------------------
245
+ # Environment variable overrides
246
+ # ------------------------------------------------------------------
247
+
248
+ _ENV_PREFIX = "RESEARCHLOOP_"
249
+
250
+
251
+ def _env(name: str) -> str | None:
252
+ """Read an env var with the RESEARCHLOOP_ prefix."""
253
+ return os.environ.get(f"{_ENV_PREFIX}{name}")
254
+
255
+
256
+ def _apply_env_overrides(config: Config) -> None:
257
+ """Override config values from environment variables.
258
+
259
+ Env vars take precedence over TOML values. Supported vars::
260
+
261
+ RESEARCHLOOP_SHARED_SECRET
262
+ RESEARCHLOOP_ORCHESTRATOR_URL
263
+ RESEARCHLOOP_DB_PATH
264
+ RESEARCHLOOP_ARTIFACT_DIR
265
+ RESEARCHLOOP_SLACK_BOT_TOKEN
266
+ RESEARCHLOOP_SLACK_SIGNING_SECRET
267
+ RESEARCHLOOP_SLACK_CHANNEL_ID
268
+ RESEARCHLOOP_SLACK_ALLOWED_USER_IDS
269
+ RESEARCHLOOP_NTFY_URL
270
+ RESEARCHLOOP_NTFY_TOPIC
271
+ RESEARCHLOOP_DASHBOARD_PASSWORD
272
+ RESEARCHLOOP_DASHBOARD_PASSWORD_HASH
273
+ RESEARCHLOOP_DASHBOARD_PORT
274
+ RESEARCHLOOP_DASHBOARD_HOST
275
+ """
276
+ # Top-level secrets / settings
277
+ if v := _env("SHARED_SECRET"):
278
+ config.shared_secret = v
279
+ if v := _env("ORCHESTRATOR_URL"):
280
+ config.orchestrator_url = v
281
+ if v := _env("DB_PATH"):
282
+ config.db_path = v
283
+ if v := _env("ARTIFACT_DIR"):
284
+ config.artifact_dir = v
285
+
286
+ # Slack
287
+ if _env("SLACK_BOT_TOKEN"):
288
+ if config.slack is None:
289
+ config.slack = SlackConfig()
290
+ config.slack.bot_token = _env("SLACK_BOT_TOKEN") or ""
291
+ if _env("SLACK_SIGNING_SECRET"):
292
+ if config.slack is None:
293
+ config.slack = SlackConfig()
294
+ config.slack.signing_secret = _env("SLACK_SIGNING_SECRET") or ""
295
+ if v := _env("SLACK_CHANNEL_ID"):
296
+ if config.slack is None:
297
+ config.slack = SlackConfig()
298
+ config.slack.channel_id = v
299
+ if v := _env("SLACK_ALLOWED_USER_IDS"):
300
+ if config.slack is None:
301
+ config.slack = SlackConfig()
302
+ config.slack.allowed_user_ids = [
303
+ uid.strip() for uid in v.split(",") if uid.strip()
304
+ ]
305
+
306
+ # ntfy
307
+ if _env("NTFY_TOPIC"):
308
+ if config.ntfy is None:
309
+ config.ntfy = NtfyConfig()
310
+ config.ntfy.topic = _env("NTFY_TOPIC") or ""
311
+ if v := _env("NTFY_URL"):
312
+ if config.ntfy is None:
313
+ config.ntfy = NtfyConfig()
314
+ config.ntfy.url = v
315
+
316
+ # Dashboard
317
+ if v := _env("DASHBOARD_PASSWORD"):
318
+ import bcrypt
319
+
320
+ config.dashboard.password_hash = bcrypt.hashpw(
321
+ v.encode("utf-8"), bcrypt.gensalt()
322
+ ).decode("utf-8")
323
+ if v := _env("DASHBOARD_PASSWORD_HASH"):
324
+ config.dashboard.password_hash = v
325
+ if v := _env("DASHBOARD_PORT"):
326
+ config.dashboard.port = int(v)
327
+ if v := _env("DASHBOARD_HOST"):
328
+ config.dashboard.host = v
@@ -0,0 +1,38 @@
1
+ """Local CLI credentials for connecting to a remote orchestrator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ _CREDENTIALS_PATH = Path.home() / ".config" / "researchloop" / "credentials.json"
9
+
10
+
11
+ def save_credentials(url: str, token: str) -> Path:
12
+ """Save orchestrator URL and API token to disk."""
13
+ _CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
14
+ _CREDENTIALS_PATH.write_text(
15
+ json.dumps({"url": url, "token": token}, indent=2) + "\n",
16
+ encoding="utf-8",
17
+ )
18
+ _CREDENTIALS_PATH.chmod(0o600)
19
+ return _CREDENTIALS_PATH
20
+
21
+
22
+ def load_credentials() -> dict[str, str] | None:
23
+ """Load saved credentials, or None if not configured."""
24
+ if not _CREDENTIALS_PATH.exists():
25
+ return None
26
+ try:
27
+ data = json.loads(_CREDENTIALS_PATH.read_text(encoding="utf-8"))
28
+ if data.get("url") and data.get("token"):
29
+ return data
30
+ return None
31
+ except (json.JSONDecodeError, OSError):
32
+ return None
33
+
34
+
35
+ def clear_credentials() -> None:
36
+ """Remove saved credentials."""
37
+ if _CREDENTIALS_PATH.exists():
38
+ _CREDENTIALS_PATH.unlink()