aicortexpy 0.2.0__tar.gz → 0.2.1__tar.gz

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 (94) hide show
  1. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/PKG-INFO +1 -1
  2. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/__init__.py +1 -1
  3. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/acp_client.py +20 -3
  4. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/acp_conn.py +2 -2
  5. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/attachments.py +3 -1
  6. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/cli.py +35 -3
  7. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/client.py +9 -2
  8. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/commands/agent_cmd.py +37 -0
  9. aicortexpy-0.2.1/aicortexpy/commands/auth_cmd.py +167 -0
  10. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/commands/autopilot_cmd.py +86 -2
  11. aicortexpy-0.2.1/aicortexpy/commands/daemon_cmd.py +228 -0
  12. aicortexpy-0.2.1/aicortexpy/commands/setup_cmd.py +159 -0
  13. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/commands/skill_cmd.py +28 -6
  14. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/commands/squad_cmd.py +22 -1
  15. aicortexpy-0.2.1/aicortexpy/commands/update_cmd.py +46 -0
  16. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/config.py +81 -7
  17. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/id_resolve.py +17 -0
  18. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/models.py +8 -0
  19. aicortexpy-0.2.1/aicortexpy/oauth_login.py +183 -0
  20. aicortexpy-0.2.1/aicortexpy/output.py +77 -0
  21. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/platform_client.py +8 -1
  22. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/process.py +2 -1
  23. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/runner.py +50 -2
  24. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy.egg-info/PKG-INFO +1 -1
  25. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy.egg-info/SOURCES.txt +3 -0
  26. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/pyproject.toml +1 -1
  27. aicortexpy-0.2.0/aicortexpy/commands/auth_cmd.py +0 -87
  28. aicortexpy-0.2.0/aicortexpy/commands/daemon_cmd.py +0 -97
  29. aicortexpy-0.2.0/aicortexpy/output.py +0 -41
  30. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/MANIFEST.in +0 -0
  31. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/README.md +0 -0
  32. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/__main__.py +0 -0
  33. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/acp_errors.py +0 -0
  34. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/agent_env.py +0 -0
  35. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/artifact_ws.py +0 -0
  36. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/artifacts.py +0 -0
  37. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/auto_commit.py +0 -0
  38. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/capabilities.py +0 -0
  39. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/chat_acp_pool.py +0 -0
  40. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/cli_context.py +0 -0
  41. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/codex_config.py +0 -0
  42. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/commands/__init__.py +0 -0
  43. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/commands/attachment_cmd.py +0 -0
  44. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/commands/config_cmd.py +0 -0
  45. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/commands/graph_cmd.py +0 -0
  46. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/commands/issue_cmd.py +0 -0
  47. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/commands/label_cmd.py +0 -0
  48. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/commands/meeting_cmd.py +0 -0
  49. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/commands/memory_cmd.py +0 -0
  50. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/commands/project_cmd.py +0 -0
  51. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/commands/repo_cmd.py +0 -0
  52. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/commands/runtime_cmd.py +0 -0
  53. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/commands/version_cmd.py +0 -0
  54. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/commands/workspace_cmd.py +0 -0
  55. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/daemon.py +0 -0
  56. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/data/default_skills/agent-memory/SKILL.md +0 -0
  57. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/data/default_skills/aicorkit-atlassian/SKILL.md +0 -0
  58. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/data/default_skills/aicorkit-graph/SKILL.md +0 -0
  59. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/data/default_skills/aicorkit-outlook/SKILL.md +0 -0
  60. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/data/default_skills/aicorkit-wxmsg/SKILL.md +0 -0
  61. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/data/default_skills/aicortex-basics/SKILL.md +0 -0
  62. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/data/default_skills/aicortex-bridge-cdp/SKILL.md +0 -0
  63. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/data/default_skills/aicortex-cli-ops/SKILL.md +0 -0
  64. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/data/default_skills/aicortex-graph/SKILL.md +0 -0
  65. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/data/default_skills/graphify/SKILL.md +0 -0
  66. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/data/default_skills/question-form/SKILL.md +0 -0
  67. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/data/default_skills/test-report-generator/SKILL.md +0 -0
  68. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/data/default_skills/test-report-generator/scripts/generate_cases.py +0 -0
  69. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/data/default_skills/test-report-generator/scripts/generate_report.py +0 -0
  70. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/design_seed.py +0 -0
  71. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/diskusage.py +0 -0
  72. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/execenv.py +0 -0
  73. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/gc.py +0 -0
  74. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/handoff_bundle.py +0 -0
  75. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/health.py +0 -0
  76. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/local_skills.py +0 -0
  77. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/openclaw_config.py +0 -0
  78. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/pinned_workdir.py +0 -0
  79. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/platform_info.py +0 -0
  80. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/poisoned.py +0 -0
  81. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/procutil.py +0 -0
  82. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/prompt.py +0 -0
  83. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/repo.py +0 -0
  84. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/runtime_config.py +0 -0
  85. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/sandbox.py +0 -0
  86. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/stream.py +0 -0
  87. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/terminal.py +0 -0
  88. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/tunnel.py +0 -0
  89. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy/wakeup.py +0 -0
  90. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy.egg-info/dependency_links.txt +0 -0
  91. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy.egg-info/entry_points.txt +0 -0
  92. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy.egg-info/requires.txt +0 -0
  93. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/aicortexpy.egg-info/top_level.txt +0 -0
  94. {aicortexpy-0.2.0 → aicortexpy-0.2.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: aicortexpy
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: AICortex Python CLI and local agent runtime daemon (Go CLI compatible)
5
5
  Author: AICortex
6
6
  License: Apache-2.0
@@ -1,3 +1,3 @@
1
1
  """AICortex Python CLI + local agent runtime daemon."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.1"
@@ -236,7 +236,9 @@ class ACPClient:
236
236
  if self.on_message:
237
237
  self.on_message(
238
238
  {
239
- "type": "tool-use",
239
+ # Server/UI wire format uses underscores (Go daemon maps
240
+ # internal "tool-use" → "tool_use" before ReportTaskMessages).
241
+ "type": "tool_use",
240
242
  "tool": tool_name,
241
243
  "call_id": call_id,
242
244
  "input": raw_input,
@@ -271,8 +273,18 @@ class ACPClient:
271
273
  output = str(data.get("rawOutput") or data.get("output") or "")
272
274
  if not output:
273
275
  output = _extract_tool_call_text(content)
276
+ tool_name = ""
277
+ if pending is not None:
278
+ tool_name = pending.tool_name or ""
274
279
  if self.on_message:
275
- self.on_message({"type": "tool-result", "call_id": call_id, "output": output})
280
+ self.on_message(
281
+ {
282
+ "type": "tool_result",
283
+ "tool": tool_name or "tool",
284
+ "call_id": call_id,
285
+ "output": output,
286
+ }
287
+ )
276
288
 
277
289
  def _emit_deferred_tool_use(self, pending: _PendingTool | None, call_id: str, data: dict) -> None:
278
290
  if pending is not None and pending.emitted:
@@ -300,7 +312,12 @@ class ACPClient:
300
312
  inp = {}
301
313
  if self.on_message:
302
314
  self.on_message(
303
- {"type": "tool-use", "tool": tool_name, "call_id": call_id, "input": inp}
315
+ {
316
+ "type": "tool_use",
317
+ "tool": tool_name,
318
+ "call_id": call_id,
319
+ "input": inp,
320
+ }
304
321
  )
305
322
 
306
323
  def _track_tool(self, call_id: str, p: _PendingTool) -> None:
@@ -226,9 +226,9 @@ class _BaseACPConn:
226
226
  typ = msg.get("type")
227
227
  if typ == "text":
228
228
  output_parts.append(str(msg.get("content") or ""))
229
- elif typ == "tool-use":
229
+ elif typ in ("tool_use", "tool-use"):
230
230
  in_flight_tools["n"] += 1
231
- elif typ == "tool-result":
231
+ elif typ in ("tool_result", "tool-result"):
232
232
  in_flight_tools["n"] = max(0, in_flight_tools["n"] - 1)
233
233
  messages.append(msg)
234
234
  if on_message:
@@ -61,7 +61,9 @@ def materialize_attachments(
61
61
  if not att_id:
62
62
  continue
63
63
  try:
64
- body, header_name = client.fetch_attachment_bytes(att_id)
64
+ body, header_name = client.fetch_attachment_bytes(
65
+ att_id, task.workspace_id
66
+ )
65
67
  except Exception as e:
66
68
  log.warning("fetch attachment failed id=%s: %s", att_id, e)
67
69
  continue
@@ -22,8 +22,10 @@ from .commands import (
22
22
  project_cmd,
23
23
  repo_cmd,
24
24
  runtime_cmd,
25
+ setup_cmd,
25
26
  skill_cmd,
26
27
  squad_cmd,
28
+ update_cmd,
27
29
  version_cmd,
28
30
  workspace_cmd,
29
31
  )
@@ -41,12 +43,31 @@ _LEGACY_DAEMON = {
41
43
  }
42
44
 
43
45
 
44
- def _setup_logging(verbose: bool = False) -> None:
45
- level = logging.DEBUG if verbose else logging.WARNING
46
+ def _setup_logging(
47
+ verbose: bool = False,
48
+ *,
49
+ log_level: str = "",
50
+ daemon_mode: bool = False,
51
+ ) -> None:
52
+ """Configure root logging.
53
+
54
+ - CLI one-shots default to WARNING (quiet).
55
+ - Daemon run defaults to INFO (useful in python-daemon.log).
56
+ - `-v` or `--log-level DEBUG` enables DEBUG.
57
+ """
58
+ if log_level:
59
+ level = getattr(logging, log_level.upper(), logging.INFO)
60
+ elif verbose:
61
+ level = logging.DEBUG
62
+ elif daemon_mode:
63
+ level = logging.INFO
64
+ else:
65
+ level = logging.WARNING
46
66
  logging.basicConfig(
47
67
  level=level,
48
68
  format="%(asctime)s %(levelname)s %(name)s: %(message)s",
49
69
  datefmt="%H:%M:%S",
70
+ force=True,
50
71
  )
51
72
 
52
73
 
@@ -80,6 +101,8 @@ def build_parser() -> argparse.ArgumentParser:
80
101
  config_cmd.add_parser(sub)
81
102
  daemon_cmd.add_parser(sub)
82
103
  runtime_cmd.add_parser(sub)
104
+ setup_cmd.add_parser(sub)
105
+ update_cmd.add_parser(sub)
83
106
  version_cmd.add_parser(sub)
84
107
  return p
85
108
 
@@ -100,8 +123,15 @@ def main(argv: list[str] | None = None) -> int:
100
123
  except SystemExit as e:
101
124
  return int(e.code) if isinstance(e.code, int) else 1
102
125
 
103
- _setup_logging(getattr(ns, "verbose", False))
104
126
  cmd = ns.command
127
+ _setup_logging(
128
+ getattr(ns, "verbose", False),
129
+ log_level=str(getattr(ns, "log_level", "") or ""),
130
+ daemon_mode=(
131
+ cmd == "daemon"
132
+ and getattr(ns, "daemon_command", "") in {"start", "run", "restart"}
133
+ ),
134
+ )
105
135
 
106
136
  handlers = {
107
137
  "issue": issue_cmd.run,
@@ -123,6 +153,8 @@ def main(argv: list[str] | None = None) -> int:
123
153
  "version": version_cmd.run,
124
154
  "login": auth_cmd.run_login,
125
155
  "auth": auth_cmd.run_auth,
156
+ "setup": setup_cmd.run,
157
+ "update": update_cmd.run,
126
158
  }
127
159
  handler = handlers.get(cmd)
128
160
  if not handler:
@@ -422,10 +422,17 @@ class DaemonClient:
422
422
  data = self.get(f"/api/daemon/workspaces/{workspace_id}/repos")
423
423
  return data if isinstance(data, dict) else {}
424
424
 
425
- def fetch_attachment_bytes(self, attachment_id: str) -> tuple[bytes, str]:
425
+ def fetch_attachment_bytes(
426
+ self, attachment_id: str, workspace_id: str = ""
427
+ ) -> tuple[bytes, str]:
426
428
  path = f"/api/daemon/attachments/{attachment_id}/bytes"
427
429
  url = self.base_url + path
428
- req = urllib.request.Request(url, headers=self._headers(), method="GET")
430
+ headers = self._headers()
431
+ wid = (workspace_id or "").strip()
432
+ if wid:
433
+ # PAT/JWT daemon auth has no workspace in context; server requires it.
434
+ headers["X-Workspace-ID"] = wid
435
+ req = urllib.request.Request(url, headers=headers, method="GET")
429
436
  try:
430
437
  with urllib.request.urlopen(req, timeout=self.timeout, context=self._ctx) as resp:
431
438
  body = resp.read(MAX_ATTACHMENT_BYTES + 1)
@@ -4,12 +4,16 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import sys
7
+ from pathlib import Path
7
8
 
8
9
  from ..cli_context import require_client
9
10
  from ..id_resolve import list_agents, resolve_by_prefix
10
11
  from ..output import print_json, str_val
11
12
  from ..platform_client import PlatformError, q
12
13
 
14
+ _AVATAR_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
15
+ _AVATAR_MAX = 5 << 20 # 5 MiB
16
+
13
17
 
14
18
  def add_parser(sub: argparse._SubParsersAction) -> None:
15
19
  p = sub.add_parser("agent", help="Work with agents")
@@ -37,6 +41,10 @@ def add_parser(sub: argparse._SubParsersAction) -> None:
37
41
  tasks = asub.add_parser("tasks", help="List agent tasks")
38
42
  tasks.add_argument("agent_id")
39
43
  tasks.add_argument("--output", choices=["json", "table"], default="json")
44
+ avatar = asub.add_parser("avatar", help="Upload an avatar image for an agent")
45
+ avatar.add_argument("agent_id")
46
+ avatar.add_argument("--file", required=True, help="Path to avatar image (.png/.jpg/.gif/.webp)")
47
+ avatar.add_argument("--output", choices=["json", "table"], default="json")
40
48
  skills = asub.add_parser("skills", help="Agent skills")
41
49
  ssub = skills.add_subparsers(dest="agent_skills_command", required=True)
42
50
  sl = ssub.add_parser("list")
@@ -95,6 +103,35 @@ def run(ns: argparse.Namespace) -> int:
95
103
  if ns.agent_command == "tasks":
96
104
  print_json(client.get(f"/api/agents/{_aid(client, ns.agent_id)}/tasks"))
97
105
  return 0
106
+ if ns.agent_command == "avatar":
107
+ path = Path(ns.file)
108
+ if not path.is_file():
109
+ print(f"file not found: {path}", file=sys.stderr)
110
+ return 1
111
+ ext = path.suffix.lower()
112
+ if ext not in _AVATAR_EXTS:
113
+ print(
114
+ f"unsupported file format {ext!r}: must be .png, .jpg, .jpeg, .gif, or .webp",
115
+ file=sys.stderr,
116
+ )
117
+ return 1
118
+ data = path.read_bytes()
119
+ if len(data) > _AVATAR_MAX:
120
+ print(f"file too large: {len(data)} bytes (max 5MB)", file=sys.stderr)
121
+ return 1
122
+ aid = _aid(client, ns.agent_id)
123
+ client.get(f"/api/agents/{aid}") # existence check
124
+ att_id, url = client.upload_file_with_url(data, str(path))
125
+ if not url:
126
+ print("upload succeeded but no url returned", file=sys.stderr)
127
+ return 1
128
+ client.put(f"/api/agents/{aid}", {"avatar_url": url})
129
+ result = {"id": att_id, "agent_id": aid, "avatar_url": url}
130
+ if ns.output == "json":
131
+ print_json(result)
132
+ else:
133
+ print(f"{att_id}\t{aid}\t{url}")
134
+ return 0
98
135
  if ns.agent_command == "skills":
99
136
  aid = _aid(client, ns.agent_id)
100
137
  if ns.agent_skills_command == "list":
@@ -0,0 +1,167 @@
1
+ """login / auth commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import getpass
7
+ import os
8
+ import sys
9
+
10
+ from .. import __version__
11
+ from ..cli_context import profile_of, resolve_server_url, resolve_token, save_cli_config
12
+ from ..config import load_cli_config
13
+ from ..oauth_login import browser_login
14
+ from ..platform_client import PlatformClient, PlatformError
15
+
16
+
17
+ def add_parser(sub: argparse._SubParsersAction) -> None:
18
+ login = sub.add_parser(
19
+ "login",
20
+ help="Authenticate (browser OAuth, or --token for PAT)",
21
+ )
22
+ login.add_argument(
23
+ "--token",
24
+ nargs="?",
25
+ const="",
26
+ default=None,
27
+ help="PAT (mul_...); pass --token alone to prompt; omit for browser OAuth",
28
+ )
29
+ login.add_argument(
30
+ "--callback-host",
31
+ default="",
32
+ help="Host the OAuth callback URL points at (auto-detected when empty)",
33
+ )
34
+
35
+ auth = sub.add_parser("auth", help="Authenticate aicortexpy with AICortex")
36
+ asub = auth.add_subparsers(dest="auth_command", required=True)
37
+ asub.add_parser("status", help="Show authentication status")
38
+ asub.add_parser("logout", help="Remove stored token")
39
+
40
+
41
+ def _resolve_app_url(ns: argparse.Namespace) -> str:
42
+ env = (
43
+ os.environ.get("AICORTEX_APP_URL", "").strip()
44
+ or os.environ.get("FRONTEND_ORIGIN", "").strip()
45
+ )
46
+ if env:
47
+ return env.rstrip("/")
48
+ cfg = load_cli_config(profile_of(ns))
49
+ return (cfg.app_url or "").rstrip("/")
50
+
51
+
52
+ def _save_token_and_workspaces(ns: argparse.Namespace, token: str, server: str) -> int:
53
+ client = PlatformClient(server, token, "", version=__version__)
54
+ try:
55
+ me = client.get("/api/me")
56
+ except PlatformError as e:
57
+ print(f"invalid token: {e}", file=sys.stderr)
58
+ return 1
59
+ profile = profile_of(ns)
60
+ cfg = load_cli_config(profile)
61
+ cfg.token = token
62
+ cfg.server_url = server
63
+ if not cfg.app_url:
64
+ cfg.app_url = _resolve_app_url(ns)
65
+ cfg.workspace_id = ""
66
+ try:
67
+ workspaces = client.get("/api/workspaces")
68
+ if isinstance(workspaces, list) and workspaces:
69
+ cfg.workspace_id = str(workspaces[0].get("id") or "")
70
+ print(f"\nFound {len(workspaces)} workspace(s):", file=sys.stderr)
71
+ for ws in workspaces:
72
+ print(f" - {ws.get('name')} ({ws.get('id')})", file=sys.stderr)
73
+ else:
74
+ print("\nNo workspaces found.", file=sys.stderr)
75
+ app = cfg.app_url or _resolve_app_url(ns)
76
+ if app:
77
+ print(
78
+ f"Create a workspace in the web dashboard: {app}/workspaces/new",
79
+ file=sys.stderr,
80
+ )
81
+ except PlatformError:
82
+ pass
83
+ save_cli_config(cfg, profile)
84
+ name = (me or {}).get("name") if isinstance(me, dict) else ""
85
+ email = (me or {}).get("email") if isinstance(me, dict) else ""
86
+ print(f"Authenticated as {name} ({email})\nToken saved to config.", file=sys.stderr)
87
+ print("\n→ Run 'aicortex daemon start' to start your local agent runtime.", file=sys.stderr)
88
+ return 0
89
+
90
+
91
+ def run_login(ns: argparse.Namespace) -> int:
92
+ # --token present (even empty const) → PAT path; otherwise browser OAuth.
93
+ if ns.token is not None:
94
+ token = ns.token
95
+ if token == "":
96
+ token = getpass.getpass("Enter your personal access token: ").strip()
97
+ token = (token or "").strip()
98
+ if not token.startswith("mul_"):
99
+ print("invalid token format: must start with mul_", file=sys.stderr)
100
+ return 1
101
+ return _save_token_and_workspaces(ns, token, resolve_server_url(ns))
102
+
103
+ server = resolve_server_url(ns)
104
+ app = _resolve_app_url(ns)
105
+ profile = profile_of(ns)
106
+ try:
107
+ browser_login(
108
+ server_url=server,
109
+ app_url=app,
110
+ profile=profile,
111
+ callback_host_flag=getattr(ns, "callback_host", "") or "",
112
+ )
113
+ except SystemExit as e:
114
+ if e.args and isinstance(e.args[0], str):
115
+ print(e.args[0], file=sys.stderr)
116
+ return 1 if e.code not in (0, None) else 0
117
+
118
+ # Pick default workspace after OAuth saved the token.
119
+ cfg = load_cli_config(profile)
120
+ token = cfg.token
121
+ if not token:
122
+ return 1
123
+ client = PlatformClient(server, token, "", version=__version__)
124
+ try:
125
+ workspaces = client.get("/api/workspaces")
126
+ if isinstance(workspaces, list) and workspaces:
127
+ cfg.workspace_id = str(workspaces[0].get("id") or "")
128
+ save_cli_config(cfg, profile)
129
+ print(f"\nFound {len(workspaces)} workspace(s):", file=sys.stderr)
130
+ for ws in workspaces:
131
+ print(f" - {ws.get('name')} ({ws.get('id')})", file=sys.stderr)
132
+ except PlatformError:
133
+ pass
134
+ print("\n→ Run 'aicortex daemon start' to start your local agent runtime.", file=sys.stderr)
135
+ return 0
136
+
137
+
138
+ def run_auth(ns: argparse.Namespace) -> int:
139
+ profile = profile_of(ns)
140
+ if ns.auth_command == "logout":
141
+ cfg = load_cli_config(profile)
142
+ cfg.token = ""
143
+ save_cli_config(cfg, profile)
144
+ print("Logged out.", file=sys.stderr)
145
+ return 0
146
+ token = resolve_token(ns)
147
+ server = resolve_server_url(ns)
148
+ if not token:
149
+ print(
150
+ "Not authenticated. Run 'aicortexpy login' (browser) or 'aicortexpy login --token mul_...'.",
151
+ file=sys.stderr,
152
+ )
153
+ return 0
154
+ client = PlatformClient(server, token, "", version=__version__)
155
+ try:
156
+ me = client.get("/api/me")
157
+ except PlatformError as e:
158
+ print(
159
+ f"Token is invalid or expired: {e}\nRun 'aicortexpy login' to re-authenticate.",
160
+ file=sys.stderr,
161
+ )
162
+ return 0
163
+ prefix = token[:12] + "..." if len(token) > 12 else token
164
+ name = (me or {}).get("name") if isinstance(me, dict) else ""
165
+ email = (me or {}).get("email") if isinstance(me, dict) else ""
166
+ print(f"Server: {server}\nUser: {name} ({email})\nToken: {prefix}", file=sys.stderr)
167
+ return 0
@@ -6,7 +6,12 @@ import argparse
6
6
  import sys
7
7
 
8
8
  from ..cli_context import require_client
9
- from ..id_resolve import list_agents, resolve_autopilot, resolve_by_prefix
9
+ from ..id_resolve import (
10
+ list_agents,
11
+ resolve_autopilot,
12
+ resolve_autopilot_trigger,
13
+ resolve_by_prefix,
14
+ )
10
15
  from ..output import print_json
11
16
  from ..platform_client import PlatformError, q
12
17
 
@@ -36,7 +41,7 @@ def add_parser(sub: argparse._SubParsersAction) -> None:
36
41
  update.add_argument("--output", choices=["json", "table"], default="json")
37
42
  delete = asub.add_parser("delete")
38
43
  delete.add_argument("autopilot_id")
39
- trigger = asub.add_parser("trigger")
44
+ trigger = asub.add_parser("trigger", help="Manually trigger an autopilot once")
40
45
  trigger.add_argument("autopilot_id")
41
46
  trigger.add_argument("--output", choices=["json", "table"], default="json")
42
47
  runs = asub.add_parser("runs")
@@ -44,6 +49,37 @@ def add_parser(sub: argparse._SubParsersAction) -> None:
44
49
  runs.add_argument("--limit", type=int, default=50)
45
50
  runs.add_argument("--output", choices=["json", "table"], default="json")
46
51
 
52
+ tadd = asub.add_parser("trigger-add", help="Add a schedule trigger to an autopilot")
53
+ tadd.add_argument("autopilot_id")
54
+ tadd.add_argument("--cron", required=True, help="Cron expression (required)")
55
+ tadd.add_argument("--timezone", default="", help="IANA timezone (default UTC)")
56
+ tadd.add_argument("--label", default="", help="Optional human-readable label")
57
+ tadd.add_argument("--output", choices=["json", "table"], default="json")
58
+
59
+ tupd = asub.add_parser("trigger-update", help="Update an existing trigger")
60
+ tupd.add_argument("autopilot_id")
61
+ tupd.add_argument("trigger_id")
62
+ tupd.add_argument("--enabled", default=None, choices=["true", "false", "1", "0", "yes", "no"])
63
+ tupd.add_argument("--cron", default=None)
64
+ tupd.add_argument("--timezone", default=None)
65
+ tupd.add_argument("--label", default=None)
66
+ tupd.add_argument("--output", choices=["json", "table"], default="json")
67
+
68
+ tdel = asub.add_parser("trigger-delete", help="Delete a trigger")
69
+ tdel.add_argument("autopilot_id")
70
+ tdel.add_argument("trigger_id")
71
+
72
+
73
+ def _parse_bool(raw: str | None) -> bool | None:
74
+ if raw is None:
75
+ return None
76
+ v = raw.strip().lower()
77
+ if v in {"true", "1", "yes", "y", "on"}:
78
+ return True
79
+ if v in {"false", "0", "no", "n", "off"}:
80
+ return False
81
+ raise SystemExit(f"invalid boolean value: {raw!r}")
82
+
47
83
 
48
84
  def run(ns: argparse.Namespace) -> int:
49
85
  client = require_client(ns)
@@ -94,6 +130,54 @@ def run(ns: argparse.Namespace) -> int:
94
130
  aid = resolve_autopilot(client, ns.autopilot_id)
95
131
  print_json(client.get(f"/api/autopilots/{aid}/runs{q({'limit': ns.limit})}"))
96
132
  return 0
133
+ if ns.autopilot_command == "trigger-add":
134
+ aid = resolve_autopilot(client, ns.autopilot_id)
135
+ body: dict = {
136
+ "kind": "schedule",
137
+ "cron_expression": ns.cron,
138
+ }
139
+ if ns.timezone:
140
+ body["timezone"] = ns.timezone
141
+ if ns.label:
142
+ body["label"] = ns.label
143
+ result = client.post(f"/api/autopilots/{aid}/triggers", body)
144
+ if ns.output == "json":
145
+ print_json(result)
146
+ else:
147
+ rid = (result or {}).get("id") if isinstance(result, dict) else ""
148
+ print(f"Trigger created: {rid}")
149
+ return 0
150
+ if ns.autopilot_command == "trigger-update":
151
+ aid = resolve_autopilot(client, ns.autopilot_id)
152
+ tid = resolve_autopilot_trigger(client, aid, ns.trigger_id)
153
+ body = {}
154
+ enabled = _parse_bool(ns.enabled)
155
+ if enabled is not None:
156
+ body["enabled"] = enabled
157
+ if ns.cron is not None:
158
+ body["cron_expression"] = ns.cron
159
+ if ns.timezone is not None:
160
+ body["timezone"] = ns.timezone
161
+ if ns.label is not None:
162
+ body["label"] = ns.label
163
+ if not body:
164
+ print(
165
+ "no fields to update; use --enabled, --cron, --timezone, or --label",
166
+ file=sys.stderr,
167
+ )
168
+ return 1
169
+ result = client.patch(f"/api/autopilots/{aid}/triggers/{tid}", body)
170
+ if ns.output == "json":
171
+ print_json(result)
172
+ else:
173
+ print(f"Trigger updated: {tid}")
174
+ return 0
175
+ if ns.autopilot_command == "trigger-delete":
176
+ aid = resolve_autopilot(client, ns.autopilot_id)
177
+ tid = resolve_autopilot_trigger(client, aid, ns.trigger_id)
178
+ client.delete(f"/api/autopilots/{aid}/triggers/{tid}")
179
+ print(f"Trigger {tid} deleted.", file=sys.stderr)
180
+ return 0
97
181
  except PlatformError as e:
98
182
  print(str(e), file=sys.stderr)
99
183
  return 1