codicent-cli 0.8.2__tar.gz → 0.8.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codicent-cli
3
- Version: 0.8.2
3
+ Version: 0.8.4
4
4
  Summary: Command-line interface for the Codicent API
5
5
  Home-page: https://github.com/izaxon/codicent-cli
6
6
  Author: Johan Isaksson
@@ -132,10 +132,28 @@ EXAMPLES:
132
132
  codicent init --template koi-studio
133
133
  codicent wrap claude
134
134
  codicent terminal
135
+ codicent daemon
135
136
  codicent update
136
137
  codicent update --install
137
138
  echo "Help me debug this" | codicent
138
139
 
140
+ DAEMON (Remote AI Outpost):
141
+ daemon Run as a long-lived background agent that polls for and
142
+ executes delegated commands from the AI (execute_command,
143
+ fs_read, fs_write, fs_list, fs_search). Use this to turn a
144
+ CLI instance into a persistent remote outpost for maintenance.
145
+
146
+ The daemon watches the Codicent project for function_result messages containing
147
+ delegation markers and executes them on behalf of the AI. Session identity ensures
148
+ only commands posted by THIS daemon instance are picked up when multiple daemons
149
+ run against the same project.
150
+
151
+ Config (~/.codicent/wrap-config.yaml):
152
+ autopilot_level: 0 # 0=always confirm, 1=confirm risky, 2=fully autonomous
153
+ allowed_paths: # Restrict fs_* tool access (empty = all paths allowed)
154
+ - /var/log
155
+ - /home/user/project
156
+
139
157
  UPDATE:
140
158
  update Check if a newer version is available on PyPI
141
159
  --install Also upgrade immediately via pip
@@ -147,7 +165,7 @@ For more information, visit: https://github.com/izaxon/codicent-cli
147
165
  """
148
166
  print(help_text.strip())
149
167
 
150
- CURRENT_VERSION = "0.8.1"
168
+ CURRENT_VERSION = "0.8.4"
151
169
 
152
170
  def show_version():
153
171
  """Display version information."""
@@ -163,6 +181,312 @@ def validate_input(question):
163
181
 
164
182
  return True, None
165
183
 
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # CLI config (shares ~/.codicent/wrap-config.yaml with the wrap command)
187
+ # ---------------------------------------------------------------------------
188
+ CLI_CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".codicent", "wrap-config.yaml")
189
+
190
+ def load_cli_config():
191
+ """Load CLI config from ~/.codicent/wrap-config.yaml."""
192
+ config = {"autopilot_level": 0, "allowed_paths": []}
193
+ if os.path.exists(CLI_CONFIG_PATH):
194
+ try:
195
+ import yaml
196
+ with open(CLI_CONFIG_PATH, "r") as f:
197
+ user_config = yaml.safe_load(f) or {}
198
+ config.update(user_config)
199
+ except Exception as e:
200
+ logger.warning(f"Could not load CLI config: {e}")
201
+ return config
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Delegation helpers
206
+ # ---------------------------------------------------------------------------
207
+ DELEGATION_PREFIXES = (
208
+ "#execute_command\n",
209
+ "#fs_read\n",
210
+ "#fs_write\n",
211
+ "#fs_list\n",
212
+ "#fs_search\n",
213
+ )
214
+
215
+
216
+ def _check_allowed_path(path, allowed_paths):
217
+ """Return True if path is within any of the allowed_paths (or if list is empty)."""
218
+ if not allowed_paths:
219
+ return True
220
+ import pathlib
221
+ try:
222
+ target = pathlib.Path(path).resolve()
223
+ except Exception:
224
+ return False
225
+ for allowed in allowed_paths:
226
+ try:
227
+ allowed_abs = pathlib.Path(allowed).resolve()
228
+ target.relative_to(allowed_abs)
229
+ return True
230
+ except ValueError:
231
+ continue
232
+ return False
233
+
234
+
235
+ def _parse_delegation(function_result_content):
236
+ """
237
+ Parse a raw #function_result message content and return
238
+ (function_name, delegation_prefix, data_dict, tool_id) if it is a delegation,
239
+ or None otherwise.
240
+
241
+ function_result_content: the full message content string from GetMessageHistory,
242
+ e.g. '@project\\n#function_result\\n{"role":"function","name":"execute_command","content":"#execute_command\\n{...}","id":"..."}'
243
+ """
244
+ # Strip @mention and leading tags
245
+ body = re.sub(r"^@\S+\s*", "", function_result_content).strip()
246
+ body = re.sub(r"^#function_result\s*\n?", "", body).strip()
247
+ try:
248
+ fr = json.loads(body)
249
+ except Exception:
250
+ return None
251
+ fc = fr.get("content", "")
252
+ for prefix in DELEGATION_PREFIXES:
253
+ if fc.startswith(prefix):
254
+ try:
255
+ data = json.loads(fc[len(prefix):])
256
+ except Exception:
257
+ return None
258
+ return fr.get("name", ""), prefix.rstrip("\n").lstrip("#"), data, fr.get("id", "")
259
+ return None
260
+
261
+
262
+ def _execute_fs_delegation(function_name, data, allowed_paths):
263
+ """Execute a filesystem delegation and return a result string."""
264
+ import pathlib
265
+ import fnmatch
266
+
267
+ if function_name == "fs_read":
268
+ path = data.get("path", "")
269
+ if not _check_allowed_path(path, allowed_paths):
270
+ return f"Error: path '{path}' is not within allowed_paths"
271
+ try:
272
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
273
+ content = f.read()
274
+ return content
275
+ except Exception as e:
276
+ return f"Error reading file: {e}"
277
+
278
+ elif function_name == "fs_write":
279
+ path = data.get("path", "")
280
+ content = data.get("content", "")
281
+ if not _check_allowed_path(path, allowed_paths):
282
+ return f"Error: path '{path}' is not within allowed_paths"
283
+ try:
284
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
285
+ with open(path, "w", encoding="utf-8") as f:
286
+ f.write(content)
287
+ return f"Written {len(content)} characters to '{path}'"
288
+ except Exception as e:
289
+ return f"Error writing file: {e}"
290
+
291
+ elif function_name == "fs_list":
292
+ path = data.get("path", "")
293
+ if not _check_allowed_path(path, allowed_paths):
294
+ return f"Error: path '{path}' is not within allowed_paths"
295
+ try:
296
+ entries = os.listdir(path)
297
+ lines = []
298
+ for entry in sorted(entries):
299
+ full = os.path.join(path, entry)
300
+ suffix = "/" if os.path.isdir(full) else ""
301
+ lines.append(entry + suffix)
302
+ return "\n".join(lines) if lines else "(empty)"
303
+ except Exception as e:
304
+ return f"Error listing directory: {e}"
305
+
306
+ elif function_name == "fs_search":
307
+ base = data.get("path", "")
308
+ pattern = data.get("pattern", "*")
309
+ query = data.get("query")
310
+ if not _check_allowed_path(base, allowed_paths):
311
+ return f"Error: path '{base}' is not within allowed_paths"
312
+ try:
313
+ matched = []
314
+ for root, dirs, files in os.walk(base):
315
+ # Prune hidden dirs
316
+ dirs[:] = [d for d in dirs if not d.startswith(".")]
317
+ for fname in files:
318
+ if fnmatch.fnmatch(fname, pattern):
319
+ fpath = os.path.join(root, fname)
320
+ if query:
321
+ try:
322
+ with open(fpath, "r", encoding="utf-8", errors="replace") as f:
323
+ if query.lower() in f.read().lower():
324
+ matched.append(fpath)
325
+ except Exception:
326
+ pass
327
+ else:
328
+ matched.append(fpath)
329
+ return "\n".join(matched[:500]) if matched else "(no matches)"
330
+ except Exception as e:
331
+ return f"Error searching: {e}"
332
+
333
+ return f"Error: unknown fs function '{function_name}'"
334
+
335
+
336
+ def _execute_shell_delegation(data, allowed_paths, autopilot_level, console, interactive, require_confirm_fn=None):
337
+ """
338
+ Execute a shell command delegation.
339
+ Returns (output_str, success_bool).
340
+ require_confirm_fn: callable(cmd) -> bool, called when autopilot_level < 2.
341
+ If None, always executes (daemon mode trusts autopilot_level).
342
+ """
343
+ cmd = data.get("command", "")
344
+ working_dir = data.get("working_directory")
345
+ timeout = data.get("timeout_seconds", 30)
346
+
347
+ if not cmd:
348
+ return "Error: empty command", False
349
+
350
+ # Autopilot gate
351
+ if require_confirm_fn is not None and autopilot_level < 2:
352
+ if not require_confirm_fn(cmd):
353
+ return "Command skipped by user.", False
354
+
355
+ try:
356
+ result = subprocess.run(
357
+ cmd,
358
+ shell=True,
359
+ capture_output=True,
360
+ text=True,
361
+ timeout=timeout,
362
+ cwd=working_dir,
363
+ encoding="utf-8",
364
+ errors="replace",
365
+ )
366
+ parts = []
367
+ if result.stdout:
368
+ parts.append(result.stdout.strip())
369
+ if result.stderr:
370
+ parts.append(f"stderr: {result.stderr.strip()}")
371
+ parts.append(f"exit_code: {result.returncode}")
372
+ return "\n".join(parts), result.returncode == 0
373
+ except subprocess.TimeoutExpired:
374
+ return f"Error: command timed out after {timeout}s", False
375
+ except Exception as e:
376
+ return f"Error: {e}", False
377
+
378
+
379
+ def _post_function_result_msg(base_url, token, verify_https, project, parent_id, function_name, result_content, tool_id):
380
+ """Post a #function_result message as a child of parent_id (the delegation message)."""
381
+ fr = {"role": "function", "name": function_name, "content": result_content, "id": tool_id or ""}
382
+ body = f"@{project}\n#function_result\n{json.dumps(fr)}"
383
+ url = f"{base_url}app/AddChatMessage"
384
+ payload = {"content": body, "type": "ai-chat-answer2", "parentId": str(parent_id)}
385
+ try:
386
+ resp = requests.post(
387
+ url,
388
+ json=payload,
389
+ headers={"Authorization": f"Bearer {token}"},
390
+ verify=verify_https,
391
+ timeout=30,
392
+ )
393
+ resp.raise_for_status()
394
+ return resp.json()
395
+ except Exception as e:
396
+ logger.error(f"Failed to post function result: {e}")
397
+ return None
398
+
399
+
400
+ def _start_chat_async(base_url, token, verify_https, project, message, conversation_id=None):
401
+ """Post chat message via StartAi2ChatAsync and return the promptMessageId."""
402
+ payload = {"message": message, "project": project}
403
+ if conversation_id:
404
+ payload["messageId"] = str(conversation_id)
405
+ url = f"{base_url}app/StartAi2ChatAsync"
406
+ resp = requests.post(
407
+ url,
408
+ json=payload,
409
+ headers={"Authorization": f"Bearer {token}"},
410
+ verify=verify_https,
411
+ timeout=30,
412
+ )
413
+ resp.raise_for_status()
414
+ return resp.json().get("promptMessageId")
415
+
416
+
417
+ def _fetch_history(base_url, token, verify_https, message_id):
418
+ """Fetch conversation history chain for the given message_id."""
419
+ url = f"{base_url}app/GetMessageHistory"
420
+ resp = requests.get(
421
+ url,
422
+ params={"messageId": str(message_id)},
423
+ headers={"Authorization": f"Bearer {token}"},
424
+ verify=verify_https,
425
+ timeout=15,
426
+ )
427
+ resp.raise_for_status()
428
+ return resp.json() if resp.text else []
429
+
430
+
431
+ def _wait_for_ai_reply_with_delegation(
432
+ base_url, token, verify_https, project, prompt_message_id,
433
+ on_delegation, poll_interval=1.5, timeout=360
434
+ ):
435
+ """
436
+ Poll GetMessageHistory until a final text reply is found.
437
+ Calls on_delegation(msg_content, msg_id) when a delegation is detected.
438
+ Returns {id, content} of the final text message, or None on timeout.
439
+ """
440
+ import time
441
+ deadline = time.time() + timeout
442
+ last_seen_id = None
443
+
444
+ while time.time() < deadline:
445
+ try:
446
+ history = _fetch_history(base_url, token, verify_https, prompt_message_id)
447
+ except Exception as e:
448
+ logger.warning(f"History fetch error: {e}")
449
+ time.sleep(poll_interval)
450
+ continue
451
+
452
+ if not history or len(history) < 2:
453
+ time.sleep(poll_interval)
454
+ continue
455
+
456
+ # Latest message in the chain (leaf)
457
+ last = history[-1]
458
+ last_id = last.get("id") or last.get("Id")
459
+ content = last.get("content") or last.get("Content", "")
460
+
461
+ if last_id == last_seen_id:
462
+ time.sleep(poll_interval)
463
+ continue
464
+ last_seen_id = last_id
465
+
466
+ # Check for delegation in function_result messages
467
+ if "#function_result" in content:
468
+ delegation = _parse_delegation(content)
469
+ if delegation:
470
+ on_delegation(content, last_id)
471
+ time.sleep(poll_interval)
472
+ continue
473
+
474
+ # Check if this is an intermediate (non-finished) engine state
475
+ intermediate_markers = [
476
+ "#chat2", "#finish_reason_function_call", "#finish_reason_tool_calls_batch",
477
+ "#function_result", "#function_results_batch",
478
+ ]
479
+ if any(m in content for m in intermediate_markers):
480
+ time.sleep(poll_interval)
481
+ continue
482
+
483
+ # Finished: strip @mention and leading tags for display
484
+ clean = re.sub(r"^@\S+\s*", "", content).strip()
485
+ clean = re.sub(r"^(#\S+\s*)+", "", clean).strip()
486
+ return {"id": last_id, "content": clean}
487
+
488
+ return None # timeout
489
+
166
490
  def main():
167
491
  console = Console()
168
492
 
@@ -542,6 +866,124 @@ def main():
542
866
  return 0 if failed == 0 else 1
543
867
 
544
868
  # Handle wrap command
869
+ # -----------------------------------------------------------------------
870
+ # daemon subcommand — persistent remote AI outpost
871
+ # -----------------------------------------------------------------------
872
+ if len(sys.argv) > 1 and sys.argv[1] == "daemon":
873
+ import time
874
+ import uuid
875
+
876
+ daemon_token = auth.get_cached_token()
877
+ if not daemon_token:
878
+ daemon_token = os.getenv("CODICENT_TOKEN")
879
+ if not daemon_token:
880
+ console.print("[red]❌ No authentication found.[/red]")
881
+ console.print("[dim]Run 'codicent auth' to authenticate, or set CODICENT_TOKEN[/dim]")
882
+ return 1
883
+
884
+ try:
885
+ dp = daemon_token.split(".")[1]
886
+ dp += '=' * (-len(dp) % 4)
887
+ daemon_jwt = json.loads(base64.urlsafe_b64decode(dp).decode('utf-8'))
888
+ daemon_project = daemon_jwt["project"]
889
+ daemon_base_url = daemon_jwt.get("baseUrl", "https://codicent.com/")
890
+ daemon_verify = True
891
+ except Exception as e:
892
+ console.print(f"[red]❌ Failed to extract project from token: {e}[/red]")
893
+ return 1
894
+
895
+ daemon_config = load_cli_config()
896
+ daemon_autopilot = daemon_config.get("autopilot_level", 0)
897
+ daemon_allowed = daemon_config.get("allowed_paths", [])
898
+ daemon_poll_interval = daemon_config.get("daemon", {}).get("poll_interval", 5)
899
+
900
+ # Stable session identity — persistent across restarts when using the same token
901
+ session_id = daemon_jwt.get("sub", "")[:16] or str(uuid.uuid4())[:8]
902
+ console.print(f"[bold green]🤖 Codicent Daemon started[/bold green] (session={session_id})")
903
+ console.print(f"[dim]Project: {daemon_project} | Autopilot level: {daemon_autopilot} | Poll: {daemon_poll_interval}s[/dim]")
904
+ if daemon_allowed:
905
+ console.print(f"[dim]Allowed paths: {daemon_allowed}[/dim]")
906
+
907
+ # Track delegation messages we have posted results for (avoid double-execution)
908
+ executed_ids = set()
909
+
910
+ # We watch for delegation markers in "pending" messages addressed via session_id
911
+ # The engine embeds session_id in every delegation JSON if provided in the tool call.
912
+ # Format: @project #function_result {"role":"function","name":"...","content":"#execute_command\n{...session_id:...}","id":"..."}
913
+
914
+ def _fetch_pending_delegations():
915
+ """Fetch unhandled (leaf) function_result messages for this project."""
916
+ url = f"{daemon_base_url}app/GetPendingDelegations"
917
+ try:
918
+ resp = requests.get(
919
+ url,
920
+ params={"project": daemon_project, "maxMessages": 50},
921
+ headers={"Authorization": f"Bearer {daemon_token}"},
922
+ verify=daemon_verify,
923
+ timeout=15,
924
+ )
925
+ resp.raise_for_status()
926
+ return resp.json() if resp.text else []
927
+ except Exception as e:
928
+ logger.debug(f"Daemon poll error: {e}")
929
+ return []
930
+
931
+ try:
932
+ while True:
933
+ msgs = _fetch_pending_delegations()
934
+ for msg in msgs:
935
+ msg_id = msg.get("id") or msg.get("Id")
936
+ if not msg_id or msg_id in executed_ids:
937
+ continue
938
+ content = msg.get("content") or msg.get("Content", "")
939
+ if "#function_result" not in content:
940
+ continue
941
+
942
+ delegation = _parse_delegation(content)
943
+ if not delegation:
944
+ continue
945
+
946
+ func_name, op, data, tool_id = delegation
947
+
948
+ # Filter by session_id if present in the delegation data
949
+ msg_session = data.get("session_id", "")
950
+ if msg_session and msg_session != session_id:
951
+ continue
952
+
953
+ # Mark as handled immediately to avoid duplicate execution
954
+ executed_ids.add(msg_id)
955
+
956
+ console.print(f"\n[bold cyan]→ Delegation:[/bold cyan] {op} | msg_id={msg_id}")
957
+
958
+ if op == "execute_command":
959
+ cmd = data.get("command", "")
960
+ console.print(f" cmd: {cmd}")
961
+ if daemon_autopilot < 1:
962
+ # Non-interactive: always require autopilot_level >= 1 for daemon
963
+ result_str = "Daemon requires autopilot_level >= 1 for execute_command. Set in ~/.codicent/wrap-config.yaml."
964
+ else:
965
+ result_str, _ = _execute_shell_delegation(
966
+ data, daemon_allowed, daemon_autopilot, console, False, None
967
+ )
968
+ else:
969
+ result_str = _execute_fs_delegation(op, data, daemon_allowed)
970
+
971
+ console.print(f" result: {result_str[:160]}{'…' if len(result_str) > 160 else ''}")
972
+ _post_function_result_msg(
973
+ daemon_base_url, daemon_token, daemon_verify,
974
+ daemon_project, msg_id, func_name, result_str, tool_id
975
+ )
976
+
977
+ # Trim executed_ids if it gets large
978
+ if len(executed_ids) > 2000:
979
+ executed_ids = set(list(executed_ids)[-1000:])
980
+
981
+ time.sleep(daemon_poll_interval)
982
+
983
+ except KeyboardInterrupt:
984
+ console.print("\n[yellow]Daemon stopped.[/yellow]")
985
+ return 0
986
+
545
987
  if len(sys.argv) > 1 and sys.argv[1] == "wrap":
546
988
  from wrap import run_wrap_command
547
989
 
@@ -627,6 +1069,9 @@ def main():
627
1069
  return 1
628
1070
 
629
1071
  conversationId = None
1072
+ cli_config = load_cli_config()
1073
+ autopilot_level = cli_config.get("autopilot_level", 0)
1074
+ allowed_paths = cli_config.get("allowed_paths", [])
630
1075
 
631
1076
  # Parse interactive mode flags
632
1077
  interactive = False
@@ -682,30 +1127,92 @@ def main():
682
1127
  console.print("[green]✅ Message posted successfully.[/green]")
683
1128
  else:
684
1129
  logger.info("Sending chat reply to Codicent API")
685
- with console.status("[dim]🤔 Thinking...[/dim]", spinner="dots"):
686
- response = codicent.post_chat_reply(question, conversationId)
687
- conversationId = response["id"]
1130
+
1131
+ # --- Async delegation loop ---
1132
+ def handle_delegation(msg_content, msg_id):
1133
+ """Called when a delegation message is found during inline wait."""
1134
+ delegation = _parse_delegation(msg_content)
1135
+ if not delegation:
1136
+ return
1137
+ func_name, op, data, tool_id = delegation
1138
+
1139
+ if op == "execute_command":
1140
+ cmd = data.get("command", "")
1141
+ console.print(f"\n[bold yellow]⚡ AI wants to run:[/bold yellow] {cmd}")
1142
+
1143
+ def confirm_fn(c):
1144
+ if autopilot_level >= 2:
1145
+ return True
1146
+ if not interactive:
1147
+ return autopilot_level >= 1
1148
+ try:
1149
+ ans = prompt("Allow? (y/N) ")
1150
+ return ans.strip().lower() == "y"
1151
+ except (KeyboardInterrupt, EOFError):
1152
+ return False
1153
+
1154
+ with console.status("[dim]Running command...[/dim]", spinner="dots"):
1155
+ result_str, _ = _execute_shell_delegation(
1156
+ data, allowed_paths, autopilot_level, console, interactive, confirm_fn
1157
+ )
1158
+ console.print(f"[dim]Result: {result_str[:200]}{'…' if len(result_str) > 200 else ''}[/dim]")
1159
+ _post_function_result_msg(
1160
+ codicent.base_url, token, codicent.verify_https,
1161
+ project, msg_id, func_name, result_str, tool_id
1162
+ )
1163
+
1164
+ elif op in ("fs_read", "fs_write", "fs_list", "fs_search"):
1165
+ path = data.get("path", "")
1166
+ console.print(f"\n[bold yellow]📂 AI filesystem op:[/bold yellow] {op} {path}")
1167
+ if not _check_allowed_path(path, allowed_paths):
1168
+ result_str = f"Error: path '{path}' is not within allowed_paths"
1169
+ else:
1170
+ with console.status("[dim]Executing...[/dim]", spinner="dots"):
1171
+ result_str = _execute_fs_delegation(op, data, allowed_paths)
1172
+ console.print(f"[dim]{result_str[:200]}{'…' if len(result_str) > 200 else ''}[/dim]")
1173
+ _post_function_result_msg(
1174
+ codicent.base_url, token, codicent.verify_https,
1175
+ project, msg_id, func_name, result_str, tool_id
1176
+ )
1177
+
1178
+ try:
1179
+ prompt_id = _start_chat_async(
1180
+ codicent.base_url, token, codicent.verify_https,
1181
+ project, question, conversationId
1182
+ )
1183
+ except Exception:
1184
+ # Fallback to synchronous post_chat_reply (no delegation support)
1185
+ with console.status("[dim]🤔 Thinking...[/dim]", spinner="dots"):
1186
+ response = codicent.post_chat_reply(question, conversationId)
1187
+ conversationId = response["id"]
1188
+ content = response["content"]
1189
+ else:
1190
+ with console.status("[dim]🤔 Thinking...[/dim]", spinner="dots"):
1191
+ response = _wait_for_ai_reply_with_delegation(
1192
+ codicent.base_url, token, codicent.verify_https,
1193
+ project, prompt_id, handle_delegation
1194
+ )
1195
+ if response is None:
1196
+ console.print("[red]⏱ Request timed out or no reply received.[/red]")
1197
+ return False
1198
+ conversationId = response["id"]
1199
+ content = response["content"]
1200
+
688
1201
  logger.info(f"Updated conversation ID: {conversationId}")
689
-
1202
+
690
1203
  # Show bot response with markdown formatting in green
691
1204
  if interactive:
692
1205
  console.print()
693
-
694
- content = response["content"]
695
-
696
- # Detect shell: markers in AI response
1206
+
1207
+ # Backward-compat: detect legacy shell: markers in AI response
697
1208
  shell_commands = re.findall(r'^shell:(.+)$', content, re.MULTILINE)
698
-
699
- # Also detect ```shell code blocks
700
1209
  shell_blocks = re.findall(r'```shell\n(.*?)```', content, re.DOTALL)
701
-
702
- # Create markdown with green styling
1210
+
703
1211
  from rich.text import Text
704
1212
  markdown_content = Markdown(content)
705
1213
  console.print(markdown_content, style="green")
706
1214
  console.print()
707
-
708
- # Offer to run detected shell commands
1215
+
709
1216
  all_commands = shell_commands + [block.strip() for block in shell_blocks]
710
1217
  for cmd in all_commands:
711
1218
  cmd = cmd.strip()
@@ -720,7 +1227,6 @@ def main():
720
1227
  if confirm.strip().lower() == 'y':
721
1228
  console.print(f"[dim]Running: {cmd}[/dim]")
722
1229
  try:
723
- # Run command with proper encoding handling for cross-platform compatibility
724
1230
  result = subprocess.run(
725
1231
  cmd,
726
1232
  shell=True,
@@ -728,10 +1234,9 @@ def main():
728
1234
  text=True,
729
1235
  timeout=60,
730
1236
  encoding='utf-8',
731
- errors='replace' # Replace invalid chars instead of failing
1237
+ errors='replace'
732
1238
  )
733
1239
 
734
- # Display output to user
735
1240
  if result.stdout:
736
1241
  console.print(result.stdout)
737
1242
  if result.stderr:
@@ -741,57 +1246,37 @@ def main():
741
1246
  else:
742
1247
  console.print(f"[red]Exit code: {result.returncode}[/red]")
743
1248
 
744
- # Send command output back to AI chat so it can see the results
745
- output_parts = []
746
- output_parts.append(f"Command executed: `{cmd}`")
747
- output_parts.append("")
1249
+ output_parts = [f"Command executed: `{cmd}`", ""]
748
1250
  if result.stdout:
749
- output_parts.append("Output:")
750
- output_parts.append("```")
751
- output_parts.append(result.stdout.strip())
752
- output_parts.append("```")
1251
+ output_parts += ["Output:", "```", result.stdout.strip(), "```"]
753
1252
  if result.stderr:
754
- output_parts.append("Errors:")
755
- output_parts.append("```")
756
- output_parts.append(result.stderr.strip())
757
- output_parts.append("```")
1253
+ output_parts += ["Errors:", "```", result.stderr.strip(), "```"]
758
1254
  output_parts.append(f"Exit code: {result.returncode}")
759
-
760
1255
  command_output = "\n".join(output_parts)
761
1256
 
762
- # Post the output back to the chat
763
- with console.status("[dim]Sending output to AI...[/dim]", spinner="dots"):
764
- response = codicent.post_chat_reply(command_output, conversationId)
765
- conversationId = response["id"]
766
-
767
- # Show AI's response to the command output
768
- console.print()
769
- console.print("[dim]AI response:[/dim]")
770
- ai_response = response["content"]
771
- markdown_content = Markdown(ai_response)
772
- console.print(markdown_content, style="green")
773
- console.print()
1257
+ try:
1258
+ prompt_id2 = _start_chat_async(
1259
+ codicent.base_url, token, codicent.verify_https,
1260
+ project, command_output, conversationId
1261
+ )
1262
+ with console.status("[dim]Sending output to AI...[/dim]", spinner="dots"):
1263
+ resp2 = _wait_for_ai_reply_with_delegation(
1264
+ codicent.base_url, token, codicent.verify_https,
1265
+ project, prompt_id2, handle_delegation
1266
+ )
1267
+ if resp2:
1268
+ conversationId = resp2["id"]
1269
+ console.print()
1270
+ console.print("[dim]AI response:[/dim]")
1271
+ console.print(Markdown(resp2["content"]), style="green")
1272
+ console.print()
1273
+ except Exception as e:
1274
+ logger.warning(f"Could not send shell output back to AI: {e}")
774
1275
 
775
1276
  except subprocess.TimeoutExpired:
776
- error_msg = "Command timed out after 60 seconds"
777
- console.print(f"[red]{error_msg}[/red]")
778
- # Send timeout info to AI
779
- try:
780
- with console.status("[dim]Sending timeout info to AI...[/dim]", spinner="dots"):
781
- response = codicent.post_chat_reply(f"Command timed out: `{cmd}`", conversationId)
782
- conversationId = response["id"]
783
- except Exception:
784
- pass # Don't fail if we can't send the timeout
1277
+ console.print("[red]Command timed out after 60 seconds[/red]")
785
1278
  except Exception as e:
786
- error_msg = f"Error: {e}"
787
- console.print(f"[red]{error_msg}[/red]")
788
- # Send error info to AI
789
- try:
790
- with console.status("[dim]Sending error info to AI...[/dim]", spinner="dots"):
791
- response = codicent.post_chat_reply(f"Command failed: `{cmd}`\nError: {str(e)}", conversationId)
792
- conversationId = response["id"]
793
- except Exception:
794
- pass # Don't fail if we can't send the error
1279
+ console.print(f"[red]Error: {e}[/red]")
795
1280
  else:
796
1281
  console.print("[dim]Skipped.[/dim]")
797
1282
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codicent-cli
3
- Version: 0.8.2
3
+ Version: 0.8.4
4
4
  Summary: Command-line interface for the Codicent API
5
5
  Home-page: https://github.com/izaxon/codicent-cli
6
6
  Author: Johan Isaksson
@@ -9,7 +9,7 @@ except FileNotFoundError:
9
9
 
10
10
  setup(
11
11
  name='codicent-cli',
12
- version='0.8.2',
12
+ version='0.8.4',
13
13
  author='Johan Isaksson',
14
14
  author_email='johan@izaxon.com',
15
15
  description='Command-line interface for the Codicent API',
@@ -70,7 +70,8 @@ def run_terminal_host(token: str, base_url: str, project: str, shell: str = None
70
70
  )
71
71
 
72
72
  print(f"[codicent terminal] project=@{project} shell={shell} session={session_id}")
73
- print(f"[codicent terminal] Connecting to {base_url} ...")
73
+ ws_display = f"{ws_base}/terminal/host?project={project}&command={os.path.basename(shell)}&sessionId={session_id}"
74
+ print(f"[codicent terminal] Connecting to {ws_display} ...")
74
75
 
75
76
  stop_event = threading.Event()
76
77
  ws_app = None
@@ -101,7 +102,9 @@ def run_terminal_host(token: str, base_url: str, project: str, shell: str = None
101
102
  _set_pty_size(cols, rows)
102
103
 
103
104
  def on_open(ws):
104
- print("[codicent terminal] Connected. Shell is ready in the browser.")
105
+ browser_url = f"{base_url.rstrip('/')}/terminal/ws?sessionId={session_id}"
106
+ print(f"[codicent terminal] Connected. Shell is ready in the browser.")
107
+ print(f"[codicent terminal] Browser connect: {browser_url}")
105
108
  # Send initial resize
106
109
  import json
107
110
  ws.send(json.dumps({"type": "resize", "cols": cols, "rows": rows}))
@@ -199,7 +202,9 @@ def run_terminal_host(token: str, base_url: str, project: str, shell: str = None
199
202
  threading.Thread(target=_stdout_reader, daemon=True).start()
200
203
 
201
204
  def on_open(ws):
202
- print("[codicent terminal] Connected.")
205
+ browser_url = f"{base_url.rstrip('/')}/terminal/ws?sessionId={session_id}"
206
+ print(f"[codicent terminal] Connected.")
207
+ print(f"[codicent terminal] Browser connect: {browser_url}")
203
208
 
204
209
  # Thread: drain queue -> WebSocket
205
210
  def _sender():
File without changes
File without changes
File without changes
File without changes
File without changes