agentram 0.1.44 → 0.1.48

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.
package/agentram/cli.py CHANGED
@@ -31,33 +31,12 @@ def looks_like_raw_api_key(value: str) -> bool:
31
31
  return not bool(ENV_NAME_PATTERN.fullmatch(stripped))
32
32
 
33
33
 
34
- def update_env_file_value(path: Path, key: str, value: str) -> None:
35
- path.parent.mkdir(parents=True, exist_ok=True)
36
- lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
37
- assignment = f"{key}={value}"
38
- updated = False
39
- output: list[str] = []
40
- for line in lines:
41
- if line.strip().startswith(f"{key}="):
42
- output.append(assignment)
43
- updated = True
44
- else:
45
- output.append(line)
46
- if not updated:
47
- if output and output[-1].strip():
48
- output.append("")
49
- output.append("# Added by AgentRAM TUI setup")
50
- output.append(assignment)
51
- path.write_text("\n".join(output) + "\n", encoding="utf-8")
52
-
53
-
54
34
  def resolve_api_key_env(slot: str, value: str) -> tuple[str, str | None]:
55
35
  stripped = value.strip()
56
36
  if not stripped:
57
37
  return "", None
58
38
  if looks_like_raw_api_key(stripped):
59
39
  env_name = f"AGENTRAM_{slot.upper()}_API_KEY"
60
- update_env_file_value(Path(".env"), env_name, stripped)
61
40
  os.environ[env_name] = stripped
62
41
  return env_name, env_name
63
42
  return stripped, None
@@ -614,7 +593,10 @@ def run_textual_tui(context: McpContext) -> bool:
614
593
  self.context.profile_store.set_agent(slot, endpoint)
615
594
  set_global_agent_if_available(self.context, slot, endpoint)
616
595
  if saved_env_name:
617
- return f"{slot}={provider}:{model} key=.env:{saved_env_name}"
596
+ save_profile_env_if_available(self.context, saved_env_name)
597
+ save_global_env_if_available(self.context, saved_env_name)
598
+ if saved_env_name:
599
+ return f"{slot}={provider}:{model} key=global-jsonl:{saved_env_name}"
618
600
  return f"{slot}={provider}:{model}"
619
601
 
620
602
  def action_save_setup(self) -> None:
@@ -1249,6 +1231,29 @@ def bind_model_text(context: McpContext, options: dict[str, object]) -> str:
1249
1231
  return f"Bound model: {endpoint.get('id')} provider={endpoint.get('provider')} model={endpoint.get('model')} role={endpoint.get('role')}"
1250
1232
 
1251
1233
 
1234
+ def save_profile_env_if_available(context: McpContext, env_name: str) -> bool:
1235
+ if not env_name:
1236
+ return False
1237
+ value = os.getenv(env_name, "")
1238
+ if not value:
1239
+ return False
1240
+ context.profile_store.set_env_value(env_name, value)
1241
+ return True
1242
+
1243
+
1244
+ def save_global_env_if_available(context: McpContext, env_name: str) -> bool:
1245
+ if not env_name or not global_agent_profile_enabled():
1246
+ return False
1247
+ value = os.getenv(env_name, "")
1248
+ if not value:
1249
+ return False
1250
+ try:
1251
+ context.global_profile_store.set_env_value(env_name, value)
1252
+ return True
1253
+ except OSError:
1254
+ return False
1255
+
1256
+
1252
1257
  def set_global_agent_if_available(context: McpContext, slot: str, endpoint: AgentModelEndpoint) -> bool:
1253
1258
  if not global_agent_profile_enabled():
1254
1259
  return False
@@ -1749,7 +1754,7 @@ def model_error_hint(message: str) -> str:
1749
1754
  if "command not found" in lower or "winerror 2" in lower or "the system cannot find the file" in lower:
1750
1755
  return message + r" | Fix: install the CLI or set Main base_url to full command path. For Codex use base_url='C:\Users\admin\AppData\Roaming\npm\codex.cmd exec' or paste codex.cmd path; AgentRAM auto-adds exec when missing."
1751
1756
  if "missing api key env" in lower:
1752
- return message + " | Fix: set environment variable, then bind using --api-key-env ENV_NAME, not raw key."
1757
+ return message + " | Fix: run AgentRAM setup again and paste raw key; AgentRAM stores it in agent_profile.jsonl, not .env."
1753
1758
  return message
1754
1759
 
1755
1760
  def claude_template_root() -> Path:
@@ -8,12 +8,69 @@ from pathlib import Path
8
8
  import re
9
9
  import shlex
10
10
  import subprocess
11
+ import sys
11
12
  import tempfile
12
13
  import urllib.error
13
14
  import urllib.request
14
15
  from typing import Any, Callable
15
16
  from uuid import uuid4
16
17
 
18
+ from .config import global_agent_profile_enabled, global_agent_profile_path
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class CliProcessResult:
23
+ returncode: int
24
+ stdout: str
25
+ stderr: str
26
+
27
+ def cli_stream_output_enabled() -> bool:
28
+ value = os.getenv("AGENTRAM_STREAM_CLI_OUTPUT", "").strip().lower()
29
+ return value in {"1", "true", "yes", "on"}
30
+
31
+ def run_cli_process(args: list[str], timeout: int) -> CliProcessResult:
32
+ if not cli_stream_output_enabled():
33
+ process = subprocess.run(
34
+ args,
35
+ check=False,
36
+ stdin=subprocess.DEVNULL,
37
+ capture_output=True,
38
+ encoding="utf-8",
39
+ timeout=timeout,
40
+ )
41
+ return CliProcessResult(process.returncode, process.stdout or "", process.stderr or "")
42
+
43
+ process = subprocess.Popen(
44
+ args,
45
+ stdin=subprocess.DEVNULL,
46
+ stdout=subprocess.PIPE,
47
+ stderr=subprocess.STDOUT,
48
+ encoding="utf-8",
49
+ errors="replace",
50
+ text=True,
51
+ )
52
+ lines: list[str] = []
53
+ try:
54
+ assert process.stdout is not None
55
+ for line in process.stdout:
56
+ lines.append(line)
57
+ print(line, end="", flush=True)
58
+ returncode = process.wait(timeout=timeout)
59
+ except subprocess.TimeoutExpired:
60
+ process.kill()
61
+ returncode = process.wait()
62
+ lines.append(f"\ncommand timed out after {timeout}s\n")
63
+ finally:
64
+ print("\033[2K\r", end="", flush=True)
65
+ return CliProcessResult(returncode, "".join(lines), "")
66
+
67
+ def add_codex_output_last_message(args: list[str], output_path: str) -> list[str]:
68
+ if "--output-last-message" in args or "-o" in args:
69
+ return args
70
+ if len(args) >= 2 and args[1] == "exec":
71
+ return [args[0], args[1], "--output-last-message", output_path, *args[2:]]
72
+ return args
73
+
17
74
 
18
75
  PROVIDER_ALIASES = {
19
76
  "openai compatible": "openai-compatible",
@@ -61,13 +118,33 @@ def split_cli_command(command: str) -> list[str]:
61
118
  return [program, *shlex.split(rest)] if rest else [program]
62
119
  return shlex.split(cleaned)
63
120
 
121
+ def global_profile_env_value(name: str) -> str:
122
+ if not global_agent_profile_enabled():
123
+ return ""
124
+ path = global_agent_profile_path()
125
+ if not path.exists():
126
+ return ""
127
+ try:
128
+ lines = [line.strip() for line in path.read_text(encoding="utf-8", errors="ignore").splitlines() if line.strip()]
129
+ if not lines:
130
+ return ""
131
+ data = json.loads(lines[-1])
132
+ except (OSError, json.JSONDecodeError):
133
+ return ""
134
+ env = data.get("env", {})
135
+ if not isinstance(env, dict):
136
+ return ""
137
+ value = env.get(name, "")
138
+ return str(value) if value else ""
139
+
140
+
64
141
  def env_value(name: str) -> str:
65
142
  value = os.getenv(name, "")
66
143
  if value:
67
144
  return value
68
145
  env_path = Path(".env")
69
146
  if not env_path.exists():
70
- return ""
147
+ return global_profile_env_value(name)
71
148
  for line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines():
72
149
  stripped = line.strip()
73
150
  if not stripped or stripped.startswith("#") or "=" not in stripped:
@@ -75,7 +152,7 @@ def env_value(name: str) -> str:
75
152
  key, raw_value = stripped.split("=", 1)
76
153
  if key.strip() == name:
77
154
  return raw_value.strip().strip('"').strip("'")
78
- return ""
155
+ return global_profile_env_value(name)
79
156
 
80
157
 
81
158
  @dataclass(frozen=True)
@@ -252,14 +329,7 @@ class MultiModelCodingOrchestrator:
252
329
  ]
253
330
  )
254
331
  try:
255
- process = subprocess.run(
256
- [*args, "-p", subagent_prompt],
257
- check=False,
258
- stdin=subprocess.DEVNULL,
259
- capture_output=True,
260
- encoding="utf-8",
261
- timeout=300,
262
- )
332
+ process = run_cli_process([*args, "-p", subagent_prompt], timeout=300)
263
333
  except FileNotFoundError as error:
264
334
  raise RuntimeError(f"Claude Code command not found: {args[0]}. Install Claude Code CLI or set base_url to the full command path.") from error
265
335
  output = (process.stdout or "").strip()
@@ -287,6 +357,7 @@ class MultiModelCodingOrchestrator:
287
357
  ]
288
358
  )
289
359
  prompt_path = ""
360
+ output_path = ""
290
361
  try:
291
362
  with tempfile.NamedTemporaryFile(
292
363
  "w",
@@ -298,15 +369,18 @@ class MultiModelCodingOrchestrator:
298
369
  ) as prompt_file:
299
370
  prompt_file.write(codex_prompt)
300
371
  prompt_path = prompt_file.name
301
- short_prompt = f"Read AgentRAM task from file and execute it: {prompt_path}"
302
- process = subprocess.run(
303
- [*args, short_prompt],
304
- check=False,
305
- stdin=subprocess.DEVNULL,
306
- capture_output=True,
372
+ with tempfile.NamedTemporaryFile(
373
+ "w",
307
374
  encoding="utf-8",
308
- timeout=int(os.getenv("AGENTRAM_CLI_TIMEOUT", "900")),
309
- )
375
+ delete=False,
376
+ dir=os.getcwd(),
377
+ prefix=".agentram_codex_final_",
378
+ suffix=".txt",
379
+ ) as output_file:
380
+ output_path = output_file.name
381
+ args = add_codex_output_last_message(args, output_path)
382
+ short_prompt = f"Read AgentRAM task from file and execute it: {prompt_path}"
383
+ process = run_cli_process([*args, short_prompt], timeout=int(os.getenv("AGENTRAM_CLI_TIMEOUT", "900")))
310
384
  except FileNotFoundError as error:
311
385
  raise RuntimeError(f"Codex CLI command not found: {args[0]}. Install Codex CLI or set base_url to the full command path.") from error
312
386
  finally:
@@ -315,7 +389,17 @@ class MultiModelCodingOrchestrator:
315
389
  os.remove(prompt_path)
316
390
  except OSError:
317
391
  pass
318
- output = (process.stdout or "").strip()
392
+ final_output = ""
393
+ if output_path:
394
+ try:
395
+ final_output = Path(output_path).read_text(encoding="utf-8").strip()
396
+ except OSError:
397
+ final_output = ""
398
+ try:
399
+ os.remove(output_path)
400
+ except OSError:
401
+ pass
402
+ output = final_output or (process.stdout or "").strip()
319
403
  error = (process.stderr or "").strip()
320
404
  if process.returncode != 0:
321
405
  raise RuntimeError(error or f"codex command failed with exit code {process.returncode}")
@@ -325,12 +409,19 @@ class MultiModelCodingOrchestrator:
325
409
  def ensure_codex_exec_defaults(args: list[str]) -> list[str]:
326
410
  if len(args) < 2 or args[1] != "exec":
327
411
  return args
328
- defaults: list[str] = []
329
- if "--skip-git-repo-check" not in args:
330
- defaults.append("--skip-git-repo-check")
331
- if "--sandbox" not in args and "-s" not in args:
332
- defaults.extend(["--sandbox", "workspace-write"])
333
- return [args[0], args[1], *defaults, *args[2:]]
412
+ result = list(args)
413
+ if "--skip-git-repo-check" not in result:
414
+ result = [result[0], result[1], "--skip-git-repo-check", *result[2:]]
415
+ sandbox_index = -1
416
+ for flag in ("--sandbox", "-s"):
417
+ if flag in result:
418
+ sandbox_index = result.index(flag)
419
+ break
420
+ if sandbox_index == -1:
421
+ result = [result[0], result[1], "--sandbox", "workspace-write", *result[2:]]
422
+ elif sandbox_index + 1 < len(result) and result[sandbox_index + 1] == "read-only":
423
+ result[sandbox_index + 1] = "workspace-write"
424
+ return result
334
425
 
335
426
 
336
427
  def parse_openai_sse(raw: str) -> str:
@@ -82,7 +82,7 @@ class JsonAgentProfileStore:
82
82
  self.path.parent.mkdir(parents=True, exist_ok=True)
83
83
 
84
84
  def default_payload(self) -> dict[str, object]:
85
- return {"updated_at": utc_now(), "endpoints": [], "router": None, "auto_route": True, "agents": {}}
85
+ return {"updated_at": utc_now(), "endpoints": [], "router": None, "auto_route": True, "agents": {}, "env": {}}
86
86
 
87
87
  def legacy_json_path(self) -> Path:
88
88
  return self.path.with_suffix(".json") if self.path.suffix == ".jsonl" else self.path
@@ -92,10 +92,11 @@ class JsonAgentProfileStore:
92
92
  data.setdefault("router", None)
93
93
  data.setdefault("auto_route", True)
94
94
  data.setdefault("agents", {})
95
+ data.setdefault("env", {})
95
96
  return data
96
97
 
97
98
  def is_empty_payload(self, data: dict[str, object]) -> bool:
98
- return not data.get("endpoints") and not data.get("router") and not data.get("agents")
99
+ return not data.get("endpoints") and not data.get("router") and not data.get("agents") and not data.get("env")
99
100
 
100
101
  def load_fallback(self) -> dict[str, object] | None:
101
102
  if not self.fallback_path or self.fallback_path == self.path or not self.fallback_path.exists():
@@ -135,6 +136,7 @@ class JsonAgentProfileStore:
135
136
  "router": current.get("router"),
136
137
  "auto_route": bool(current.get("auto_route", True)),
137
138
  "agents": current.get("agents", {}),
139
+ "env": current.get("env", {}),
138
140
  }
139
141
  self.write_snapshot(payload)
140
142
  return payload
@@ -208,6 +210,24 @@ class JsonAgentProfileStore:
208
210
  self.write_snapshot(current)
209
211
  return current
210
212
 
213
+ def set_env_value(self, name: str, value: str) -> dict[str, object]:
214
+ current = self.load()
215
+ env = current.get("env", {})
216
+ if not isinstance(env, dict):
217
+ env = {}
218
+ env[name] = value
219
+ current["env"] = env
220
+ current["updated_at"] = utc_now()
221
+ self.write_snapshot(current)
222
+ return current
223
+
224
+ def get_env_value(self, name: str) -> str:
225
+ env = self.load().get("env", {})
226
+ if not isinstance(env, dict):
227
+ return ""
228
+ value = env.get(name, "")
229
+ return str(value) if value else ""
230
+
211
231
  def get_agent(self, slot: str) -> AgentModelEndpoint | None:
212
232
  agents = self.load().get("agents", {})
213
233
  if not isinstance(agents, dict):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentram",
3
- "version": "0.1.44",
3
+ "version": "0.1.48",
4
4
  "description": "Async, model-agnostic Working RAM for coding agents.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/trancongnghia/AGENT_RAM#readme",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentram"
7
- version = "0.1.44"
7
+ version = "0.1.48"
8
8
  description = "Async, model-agnostic RAM layer for agentic coding tools."
9
9
  readme = "README.md"
10
10
  license = "MIT"