codicent-cli 0.8.3__tar.gz → 0.8.5__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.3
3
+ Version: 0.8.5
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
@@ -123,6 +123,23 @@ TERMINAL (Web Terminal Host):
123
123
  codicent terminal bash
124
124
  codicent terminal zsh
125
125
 
126
+ TUI (Text User Interface):
127
+ tui Open a split-screen terminal UI with a logbook home panel on
128
+ the left (recent messages + compose) and an AI chat panel on
129
+ the right. Uses the same authentication as other commands.
130
+
131
+ Key bindings:
132
+ Tab / Shift+Tab Switch focus between compose and chat inputs
133
+ Enter Send the current input
134
+ Escape + Enter Insert a newline in the active input
135
+ Ctrl+R Refresh message list
136
+ F1 or Alt+1 Focus compose input
137
+ F2 or Alt+2 Focus chat input
138
+ Ctrl+C Quit
139
+
140
+ Example:
141
+ codicent tui
142
+
126
143
  EXAMPLES:
127
144
  codicent auth
128
145
  codicent "What is Python?"
@@ -132,10 +149,29 @@ EXAMPLES:
132
149
  codicent init --template koi-studio
133
150
  codicent wrap claude
134
151
  codicent terminal
152
+ codicent daemon
153
+ codicent tui
135
154
  codicent update
136
155
  codicent update --install
137
156
  echo "Help me debug this" | codicent
138
157
 
158
+ DAEMON (Remote AI Outpost):
159
+ daemon Run as a long-lived background agent that polls for and
160
+ executes delegated commands from the AI (execute_command,
161
+ fs_read, fs_write, fs_list, fs_search). Use this to turn a
162
+ CLI instance into a persistent remote outpost for maintenance.
163
+
164
+ The daemon watches the Codicent project for function_result messages containing
165
+ delegation markers and executes them on behalf of the AI. Session identity ensures
166
+ only commands posted by THIS daemon instance are picked up when multiple daemons
167
+ run against the same project.
168
+
169
+ Config (~/.codicent/wrap-config.yaml):
170
+ autopilot_level: 0 # 0=always confirm, 1=confirm risky, 2=fully autonomous
171
+ allowed_paths: # Restrict fs_* tool access (empty = all paths allowed)
172
+ - /var/log
173
+ - /home/user/project
174
+
139
175
  UPDATE:
140
176
  update Check if a newer version is available on PyPI
141
177
  --install Also upgrade immediately via pip
@@ -147,7 +183,7 @@ For more information, visit: https://github.com/izaxon/codicent-cli
147
183
  """
148
184
  print(help_text.strip())
149
185
 
150
- CURRENT_VERSION = "0.8.3"
186
+ CURRENT_VERSION = "0.8.4"
151
187
 
152
188
  def show_version():
153
189
  """Display version information."""
@@ -163,6 +199,312 @@ def validate_input(question):
163
199
 
164
200
  return True, None
165
201
 
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # CLI config (shares ~/.codicent/wrap-config.yaml with the wrap command)
205
+ # ---------------------------------------------------------------------------
206
+ CLI_CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".codicent", "wrap-config.yaml")
207
+
208
+ def load_cli_config():
209
+ """Load CLI config from ~/.codicent/wrap-config.yaml."""
210
+ config = {"autopilot_level": 0, "allowed_paths": []}
211
+ if os.path.exists(CLI_CONFIG_PATH):
212
+ try:
213
+ import yaml
214
+ with open(CLI_CONFIG_PATH, "r") as f:
215
+ user_config = yaml.safe_load(f) or {}
216
+ config.update(user_config)
217
+ except Exception as e:
218
+ logger.warning(f"Could not load CLI config: {e}")
219
+ return config
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Delegation helpers
224
+ # ---------------------------------------------------------------------------
225
+ DELEGATION_PREFIXES = (
226
+ "#execute_command\n",
227
+ "#fs_read\n",
228
+ "#fs_write\n",
229
+ "#fs_list\n",
230
+ "#fs_search\n",
231
+ )
232
+
233
+
234
+ def _check_allowed_path(path, allowed_paths):
235
+ """Return True if path is within any of the allowed_paths (or if list is empty)."""
236
+ if not allowed_paths:
237
+ return True
238
+ import pathlib
239
+ try:
240
+ target = pathlib.Path(path).resolve()
241
+ except Exception:
242
+ return False
243
+ for allowed in allowed_paths:
244
+ try:
245
+ allowed_abs = pathlib.Path(allowed).resolve()
246
+ target.relative_to(allowed_abs)
247
+ return True
248
+ except ValueError:
249
+ continue
250
+ return False
251
+
252
+
253
+ def _parse_delegation(function_result_content):
254
+ """
255
+ Parse a raw #function_result message content and return
256
+ (function_name, delegation_prefix, data_dict, tool_id) if it is a delegation,
257
+ or None otherwise.
258
+
259
+ function_result_content: the full message content string from GetMessageHistory,
260
+ e.g. '@project\\n#function_result\\n{"role":"function","name":"execute_command","content":"#execute_command\\n{...}","id":"..."}'
261
+ """
262
+ # Strip @mention and leading tags
263
+ body = re.sub(r"^@\S+\s*", "", function_result_content).strip()
264
+ body = re.sub(r"^#function_result\s*\n?", "", body).strip()
265
+ try:
266
+ fr = json.loads(body)
267
+ except Exception:
268
+ return None
269
+ fc = fr.get("content", "")
270
+ for prefix in DELEGATION_PREFIXES:
271
+ if fc.startswith(prefix):
272
+ try:
273
+ data = json.loads(fc[len(prefix):])
274
+ except Exception:
275
+ return None
276
+ return fr.get("name", ""), prefix.rstrip("\n").lstrip("#"), data, fr.get("id", "")
277
+ return None
278
+
279
+
280
+ def _execute_fs_delegation(function_name, data, allowed_paths):
281
+ """Execute a filesystem delegation and return a result string."""
282
+ import pathlib
283
+ import fnmatch
284
+
285
+ if function_name == "fs_read":
286
+ path = data.get("path", "")
287
+ if not _check_allowed_path(path, allowed_paths):
288
+ return f"Error: path '{path}' is not within allowed_paths"
289
+ try:
290
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
291
+ content = f.read()
292
+ return content
293
+ except Exception as e:
294
+ return f"Error reading file: {e}"
295
+
296
+ elif function_name == "fs_write":
297
+ path = data.get("path", "")
298
+ content = data.get("content", "")
299
+ if not _check_allowed_path(path, allowed_paths):
300
+ return f"Error: path '{path}' is not within allowed_paths"
301
+ try:
302
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
303
+ with open(path, "w", encoding="utf-8") as f:
304
+ f.write(content)
305
+ return f"Written {len(content)} characters to '{path}'"
306
+ except Exception as e:
307
+ return f"Error writing file: {e}"
308
+
309
+ elif function_name == "fs_list":
310
+ path = data.get("path", "")
311
+ if not _check_allowed_path(path, allowed_paths):
312
+ return f"Error: path '{path}' is not within allowed_paths"
313
+ try:
314
+ entries = os.listdir(path)
315
+ lines = []
316
+ for entry in sorted(entries):
317
+ full = os.path.join(path, entry)
318
+ suffix = "/" if os.path.isdir(full) else ""
319
+ lines.append(entry + suffix)
320
+ return "\n".join(lines) if lines else "(empty)"
321
+ except Exception as e:
322
+ return f"Error listing directory: {e}"
323
+
324
+ elif function_name == "fs_search":
325
+ base = data.get("path", "")
326
+ pattern = data.get("pattern", "*")
327
+ query = data.get("query")
328
+ if not _check_allowed_path(base, allowed_paths):
329
+ return f"Error: path '{base}' is not within allowed_paths"
330
+ try:
331
+ matched = []
332
+ for root, dirs, files in os.walk(base):
333
+ # Prune hidden dirs
334
+ dirs[:] = [d for d in dirs if not d.startswith(".")]
335
+ for fname in files:
336
+ if fnmatch.fnmatch(fname, pattern):
337
+ fpath = os.path.join(root, fname)
338
+ if query:
339
+ try:
340
+ with open(fpath, "r", encoding="utf-8", errors="replace") as f:
341
+ if query.lower() in f.read().lower():
342
+ matched.append(fpath)
343
+ except Exception:
344
+ pass
345
+ else:
346
+ matched.append(fpath)
347
+ return "\n".join(matched[:500]) if matched else "(no matches)"
348
+ except Exception as e:
349
+ return f"Error searching: {e}"
350
+
351
+ return f"Error: unknown fs function '{function_name}'"
352
+
353
+
354
+ def _execute_shell_delegation(data, allowed_paths, autopilot_level, console, interactive, require_confirm_fn=None):
355
+ """
356
+ Execute a shell command delegation.
357
+ Returns (output_str, success_bool).
358
+ require_confirm_fn: callable(cmd) -> bool, called when autopilot_level < 2.
359
+ If None, always executes (daemon mode trusts autopilot_level).
360
+ """
361
+ cmd = data.get("command", "")
362
+ working_dir = data.get("working_directory")
363
+ timeout = data.get("timeout_seconds", 30)
364
+
365
+ if not cmd:
366
+ return "Error: empty command", False
367
+
368
+ # Autopilot gate
369
+ if require_confirm_fn is not None and autopilot_level < 2:
370
+ if not require_confirm_fn(cmd):
371
+ return "Command skipped by user.", False
372
+
373
+ try:
374
+ result = subprocess.run(
375
+ cmd,
376
+ shell=True,
377
+ capture_output=True,
378
+ text=True,
379
+ timeout=timeout,
380
+ cwd=working_dir,
381
+ encoding="utf-8",
382
+ errors="replace",
383
+ )
384
+ parts = []
385
+ if result.stdout:
386
+ parts.append(result.stdout.strip())
387
+ if result.stderr:
388
+ parts.append(f"stderr: {result.stderr.strip()}")
389
+ parts.append(f"exit_code: {result.returncode}")
390
+ return "\n".join(parts), result.returncode == 0
391
+ except subprocess.TimeoutExpired:
392
+ return f"Error: command timed out after {timeout}s", False
393
+ except Exception as e:
394
+ return f"Error: {e}", False
395
+
396
+
397
+ def _post_function_result_msg(base_url, token, verify_https, project, parent_id, function_name, result_content, tool_id):
398
+ """Post a #function_result message as a child of parent_id (the delegation message)."""
399
+ fr = {"role": "function", "name": function_name, "content": result_content, "id": tool_id or ""}
400
+ body = f"@{project}\n#function_result\n{json.dumps(fr)}"
401
+ url = f"{base_url}app/AddChatMessage"
402
+ payload = {"content": body, "type": "ai-chat-answer2", "parentId": str(parent_id)}
403
+ try:
404
+ resp = requests.post(
405
+ url,
406
+ json=payload,
407
+ headers={"Authorization": f"Bearer {token}"},
408
+ verify=verify_https,
409
+ timeout=30,
410
+ )
411
+ resp.raise_for_status()
412
+ return resp.json()
413
+ except Exception as e:
414
+ logger.error(f"Failed to post function result: {e}")
415
+ return None
416
+
417
+
418
+ def _start_chat_async(base_url, token, verify_https, project, message, conversation_id=None):
419
+ """Post chat message via StartAi2ChatAsync and return the promptMessageId."""
420
+ payload = {"message": message, "project": project}
421
+ if conversation_id:
422
+ payload["messageId"] = str(conversation_id)
423
+ url = f"{base_url}app/StartAi2ChatAsync"
424
+ resp = requests.post(
425
+ url,
426
+ json=payload,
427
+ headers={"Authorization": f"Bearer {token}"},
428
+ verify=verify_https,
429
+ timeout=30,
430
+ )
431
+ resp.raise_for_status()
432
+ return resp.json().get("promptMessageId")
433
+
434
+
435
+ def _fetch_history(base_url, token, verify_https, message_id):
436
+ """Fetch conversation history chain for the given message_id."""
437
+ url = f"{base_url}app/GetMessageHistory"
438
+ resp = requests.get(
439
+ url,
440
+ params={"messageId": str(message_id)},
441
+ headers={"Authorization": f"Bearer {token}"},
442
+ verify=verify_https,
443
+ timeout=15,
444
+ )
445
+ resp.raise_for_status()
446
+ return resp.json() if resp.text else []
447
+
448
+
449
+ def _wait_for_ai_reply_with_delegation(
450
+ base_url, token, verify_https, project, prompt_message_id,
451
+ on_delegation, poll_interval=1.5, timeout=360
452
+ ):
453
+ """
454
+ Poll GetMessageHistory until a final text reply is found.
455
+ Calls on_delegation(msg_content, msg_id) when a delegation is detected.
456
+ Returns {id, content} of the final text message, or None on timeout.
457
+ """
458
+ import time
459
+ deadline = time.time() + timeout
460
+ last_seen_id = None
461
+
462
+ while time.time() < deadline:
463
+ try:
464
+ history = _fetch_history(base_url, token, verify_https, prompt_message_id)
465
+ except Exception as e:
466
+ logger.warning(f"History fetch error: {e}")
467
+ time.sleep(poll_interval)
468
+ continue
469
+
470
+ if not history or len(history) < 2:
471
+ time.sleep(poll_interval)
472
+ continue
473
+
474
+ # Latest message in the chain (leaf)
475
+ last = history[-1]
476
+ last_id = last.get("id") or last.get("Id")
477
+ content = last.get("content") or last.get("Content", "")
478
+
479
+ if last_id == last_seen_id:
480
+ time.sleep(poll_interval)
481
+ continue
482
+ last_seen_id = last_id
483
+
484
+ # Check for delegation in function_result messages
485
+ if "#function_result" in content:
486
+ delegation = _parse_delegation(content)
487
+ if delegation:
488
+ on_delegation(content, last_id)
489
+ time.sleep(poll_interval)
490
+ continue
491
+
492
+ # Check if this is an intermediate (non-finished) engine state
493
+ intermediate_markers = [
494
+ "#chat2", "#finish_reason_function_call", "#finish_reason_tool_calls_batch",
495
+ "#function_result", "#function_results_batch",
496
+ ]
497
+ if any(m in content for m in intermediate_markers):
498
+ time.sleep(poll_interval)
499
+ continue
500
+
501
+ # Finished: strip @mention and leading tags for display
502
+ clean = re.sub(r"^@\S+\s*", "", content).strip()
503
+ clean = re.sub(r"^(#\S+\s*)+", "", clean).strip()
504
+ return {"id": last_id, "content": clean}
505
+
506
+ return None # timeout
507
+
166
508
  def main():
167
509
  console = Console()
168
510
 
@@ -542,6 +884,124 @@ def main():
542
884
  return 0 if failed == 0 else 1
543
885
 
544
886
  # Handle wrap command
887
+ # -----------------------------------------------------------------------
888
+ # daemon subcommand — persistent remote AI outpost
889
+ # -----------------------------------------------------------------------
890
+ if len(sys.argv) > 1 and sys.argv[1] == "daemon":
891
+ import time
892
+ import uuid
893
+
894
+ daemon_token = auth.get_cached_token()
895
+ if not daemon_token:
896
+ daemon_token = os.getenv("CODICENT_TOKEN")
897
+ if not daemon_token:
898
+ console.print("[red]❌ No authentication found.[/red]")
899
+ console.print("[dim]Run 'codicent auth' to authenticate, or set CODICENT_TOKEN[/dim]")
900
+ return 1
901
+
902
+ try:
903
+ dp = daemon_token.split(".")[1]
904
+ dp += '=' * (-len(dp) % 4)
905
+ daemon_jwt = json.loads(base64.urlsafe_b64decode(dp).decode('utf-8'))
906
+ daemon_project = daemon_jwt["project"]
907
+ daemon_base_url = daemon_jwt.get("baseUrl", "https://codicent.com/")
908
+ daemon_verify = True
909
+ except Exception as e:
910
+ console.print(f"[red]❌ Failed to extract project from token: {e}[/red]")
911
+ return 1
912
+
913
+ daemon_config = load_cli_config()
914
+ daemon_autopilot = daemon_config.get("autopilot_level", 0)
915
+ daemon_allowed = daemon_config.get("allowed_paths", [])
916
+ daemon_poll_interval = daemon_config.get("daemon", {}).get("poll_interval", 5)
917
+
918
+ # Stable session identity — persistent across restarts when using the same token
919
+ session_id = daemon_jwt.get("sub", "")[:16] or str(uuid.uuid4())[:8]
920
+ console.print(f"[bold green]🤖 Codicent Daemon started[/bold green] (session={session_id})")
921
+ console.print(f"[dim]Project: {daemon_project} | Autopilot level: {daemon_autopilot} | Poll: {daemon_poll_interval}s[/dim]")
922
+ if daemon_allowed:
923
+ console.print(f"[dim]Allowed paths: {daemon_allowed}[/dim]")
924
+
925
+ # Track delegation messages we have posted results for (avoid double-execution)
926
+ executed_ids = set()
927
+
928
+ # We watch for delegation markers in "pending" messages addressed via session_id
929
+ # The engine embeds session_id in every delegation JSON if provided in the tool call.
930
+ # Format: @project #function_result {"role":"function","name":"...","content":"#execute_command\n{...session_id:...}","id":"..."}
931
+
932
+ def _fetch_pending_delegations():
933
+ """Fetch unhandled (leaf) function_result messages for this project."""
934
+ url = f"{daemon_base_url}app/GetPendingDelegations"
935
+ try:
936
+ resp = requests.get(
937
+ url,
938
+ params={"project": daemon_project, "maxMessages": 50},
939
+ headers={"Authorization": f"Bearer {daemon_token}"},
940
+ verify=daemon_verify,
941
+ timeout=15,
942
+ )
943
+ resp.raise_for_status()
944
+ return resp.json() if resp.text else []
945
+ except Exception as e:
946
+ logger.debug(f"Daemon poll error: {e}")
947
+ return []
948
+
949
+ try:
950
+ while True:
951
+ msgs = _fetch_pending_delegations()
952
+ for msg in msgs:
953
+ msg_id = msg.get("id") or msg.get("Id")
954
+ if not msg_id or msg_id in executed_ids:
955
+ continue
956
+ content = msg.get("content") or msg.get("Content", "")
957
+ if "#function_result" not in content:
958
+ continue
959
+
960
+ delegation = _parse_delegation(content)
961
+ if not delegation:
962
+ continue
963
+
964
+ func_name, op, data, tool_id = delegation
965
+
966
+ # Filter by session_id if present in the delegation data
967
+ msg_session = data.get("session_id", "")
968
+ if msg_session and msg_session != session_id:
969
+ continue
970
+
971
+ # Mark as handled immediately to avoid duplicate execution
972
+ executed_ids.add(msg_id)
973
+
974
+ console.print(f"\n[bold cyan]→ Delegation:[/bold cyan] {op} | msg_id={msg_id}")
975
+
976
+ if op == "execute_command":
977
+ cmd = data.get("command", "")
978
+ console.print(f" cmd: {cmd}")
979
+ if daemon_autopilot < 1:
980
+ # Non-interactive: always require autopilot_level >= 1 for daemon
981
+ result_str = "Daemon requires autopilot_level >= 1 for execute_command. Set in ~/.codicent/wrap-config.yaml."
982
+ else:
983
+ result_str, _ = _execute_shell_delegation(
984
+ data, daemon_allowed, daemon_autopilot, console, False, None
985
+ )
986
+ else:
987
+ result_str = _execute_fs_delegation(op, data, daemon_allowed)
988
+
989
+ console.print(f" result: {result_str[:160]}{'…' if len(result_str) > 160 else ''}")
990
+ _post_function_result_msg(
991
+ daemon_base_url, daemon_token, daemon_verify,
992
+ daemon_project, msg_id, func_name, result_str, tool_id
993
+ )
994
+
995
+ # Trim executed_ids if it gets large
996
+ if len(executed_ids) > 2000:
997
+ executed_ids = set(list(executed_ids)[-1000:])
998
+
999
+ time.sleep(daemon_poll_interval)
1000
+
1001
+ except KeyboardInterrupt:
1002
+ console.print("\n[yellow]Daemon stopped.[/yellow]")
1003
+ return 0
1004
+
545
1005
  if len(sys.argv) > 1 and sys.argv[1] == "wrap":
546
1006
  from wrap import run_wrap_command
547
1007
 
@@ -573,6 +1033,31 @@ def main():
573
1033
  project=wrap_project,
574
1034
  )
575
1035
 
1036
+ # Handle tui command
1037
+ if len(sys.argv) > 1 and sys.argv[1] == "tui":
1038
+ from tui import run_tui
1039
+
1040
+ tui_token = auth.get_cached_token()
1041
+ if not tui_token:
1042
+ tui_token = os.getenv("CODICENT_TOKEN")
1043
+ if not tui_token:
1044
+ console.print("[red]❌ No authentication found.[/red]")
1045
+ console.print("[dim]Run 'codicent auth' to authenticate, or set CODICENT_TOKEN[/dim]")
1046
+ return 1
1047
+
1048
+ try:
1049
+ tui_payload = tui_token.split(".")[1]
1050
+ tui_payload += '=' * (-len(tui_payload) % 4)
1051
+ tui_jwt = json.loads(base64.urlsafe_b64decode(tui_payload).decode('utf-8'))
1052
+ tui_project = tui_jwt["project"]
1053
+ except Exception as e:
1054
+ console.print(f"[red]❌ Failed to extract project from token: {e}[/red]")
1055
+ return 1
1056
+
1057
+ tui_codicent = Codicent(tui_token)
1058
+ run_tui(tui_codicent, tui_project)
1059
+ return 0
1060
+
576
1061
  # Handle terminal command
577
1062
  if len(sys.argv) > 1 and sys.argv[1] == "terminal":
578
1063
  from terminal import run_terminal_host
@@ -627,6 +1112,9 @@ def main():
627
1112
  return 1
628
1113
 
629
1114
  conversationId = None
1115
+ cli_config = load_cli_config()
1116
+ autopilot_level = cli_config.get("autopilot_level", 0)
1117
+ allowed_paths = cli_config.get("allowed_paths", [])
630
1118
 
631
1119
  # Parse interactive mode flags
632
1120
  interactive = False
@@ -682,30 +1170,92 @@ def main():
682
1170
  console.print("[green]✅ Message posted successfully.[/green]")
683
1171
  else:
684
1172
  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"]
1173
+
1174
+ # --- Async delegation loop ---
1175
+ def handle_delegation(msg_content, msg_id):
1176
+ """Called when a delegation message is found during inline wait."""
1177
+ delegation = _parse_delegation(msg_content)
1178
+ if not delegation:
1179
+ return
1180
+ func_name, op, data, tool_id = delegation
1181
+
1182
+ if op == "execute_command":
1183
+ cmd = data.get("command", "")
1184
+ console.print(f"\n[bold yellow]⚡ AI wants to run:[/bold yellow] {cmd}")
1185
+
1186
+ def confirm_fn(c):
1187
+ if autopilot_level >= 2:
1188
+ return True
1189
+ if not interactive:
1190
+ return autopilot_level >= 1
1191
+ try:
1192
+ ans = prompt("Allow? (y/N) ")
1193
+ return ans.strip().lower() == "y"
1194
+ except (KeyboardInterrupt, EOFError):
1195
+ return False
1196
+
1197
+ with console.status("[dim]Running command...[/dim]", spinner="dots"):
1198
+ result_str, _ = _execute_shell_delegation(
1199
+ data, allowed_paths, autopilot_level, console, interactive, confirm_fn
1200
+ )
1201
+ console.print(f"[dim]Result: {result_str[:200]}{'…' if len(result_str) > 200 else ''}[/dim]")
1202
+ _post_function_result_msg(
1203
+ codicent.base_url, token, codicent.verify_https,
1204
+ project, msg_id, func_name, result_str, tool_id
1205
+ )
1206
+
1207
+ elif op in ("fs_read", "fs_write", "fs_list", "fs_search"):
1208
+ path = data.get("path", "")
1209
+ console.print(f"\n[bold yellow]📂 AI filesystem op:[/bold yellow] {op} {path}")
1210
+ if not _check_allowed_path(path, allowed_paths):
1211
+ result_str = f"Error: path '{path}' is not within allowed_paths"
1212
+ else:
1213
+ with console.status("[dim]Executing...[/dim]", spinner="dots"):
1214
+ result_str = _execute_fs_delegation(op, data, allowed_paths)
1215
+ console.print(f"[dim]{result_str[:200]}{'…' if len(result_str) > 200 else ''}[/dim]")
1216
+ _post_function_result_msg(
1217
+ codicent.base_url, token, codicent.verify_https,
1218
+ project, msg_id, func_name, result_str, tool_id
1219
+ )
1220
+
1221
+ try:
1222
+ prompt_id = _start_chat_async(
1223
+ codicent.base_url, token, codicent.verify_https,
1224
+ project, question, conversationId
1225
+ )
1226
+ except Exception:
1227
+ # Fallback to synchronous post_chat_reply (no delegation support)
1228
+ with console.status("[dim]🤔 Thinking...[/dim]", spinner="dots"):
1229
+ response = codicent.post_chat_reply(question, conversationId)
1230
+ conversationId = response["id"]
1231
+ content = response["content"]
1232
+ else:
1233
+ with console.status("[dim]🤔 Thinking...[/dim]", spinner="dots"):
1234
+ response = _wait_for_ai_reply_with_delegation(
1235
+ codicent.base_url, token, codicent.verify_https,
1236
+ project, prompt_id, handle_delegation
1237
+ )
1238
+ if response is None:
1239
+ console.print("[red]⏱ Request timed out or no reply received.[/red]")
1240
+ return False
1241
+ conversationId = response["id"]
1242
+ content = response["content"]
1243
+
688
1244
  logger.info(f"Updated conversation ID: {conversationId}")
689
-
1245
+
690
1246
  # Show bot response with markdown formatting in green
691
1247
  if interactive:
692
1248
  console.print()
693
-
694
- content = response["content"]
695
-
696
- # Detect shell: markers in AI response
1249
+
1250
+ # Backward-compat: detect legacy shell: markers in AI response
697
1251
  shell_commands = re.findall(r'^shell:(.+)$', content, re.MULTILINE)
698
-
699
- # Also detect ```shell code blocks
700
1252
  shell_blocks = re.findall(r'```shell\n(.*?)```', content, re.DOTALL)
701
-
702
- # Create markdown with green styling
1253
+
703
1254
  from rich.text import Text
704
1255
  markdown_content = Markdown(content)
705
1256
  console.print(markdown_content, style="green")
706
1257
  console.print()
707
-
708
- # Offer to run detected shell commands
1258
+
709
1259
  all_commands = shell_commands + [block.strip() for block in shell_blocks]
710
1260
  for cmd in all_commands:
711
1261
  cmd = cmd.strip()
@@ -720,7 +1270,6 @@ def main():
720
1270
  if confirm.strip().lower() == 'y':
721
1271
  console.print(f"[dim]Running: {cmd}[/dim]")
722
1272
  try:
723
- # Run command with proper encoding handling for cross-platform compatibility
724
1273
  result = subprocess.run(
725
1274
  cmd,
726
1275
  shell=True,
@@ -728,10 +1277,9 @@ def main():
728
1277
  text=True,
729
1278
  timeout=60,
730
1279
  encoding='utf-8',
731
- errors='replace' # Replace invalid chars instead of failing
1280
+ errors='replace'
732
1281
  )
733
1282
 
734
- # Display output to user
735
1283
  if result.stdout:
736
1284
  console.print(result.stdout)
737
1285
  if result.stderr:
@@ -741,57 +1289,37 @@ def main():
741
1289
  else:
742
1290
  console.print(f"[red]Exit code: {result.returncode}[/red]")
743
1291
 
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("")
1292
+ output_parts = [f"Command executed: `{cmd}`", ""]
748
1293
  if result.stdout:
749
- output_parts.append("Output:")
750
- output_parts.append("```")
751
- output_parts.append(result.stdout.strip())
752
- output_parts.append("```")
1294
+ output_parts += ["Output:", "```", result.stdout.strip(), "```"]
753
1295
  if result.stderr:
754
- output_parts.append("Errors:")
755
- output_parts.append("```")
756
- output_parts.append(result.stderr.strip())
757
- output_parts.append("```")
1296
+ output_parts += ["Errors:", "```", result.stderr.strip(), "```"]
758
1297
  output_parts.append(f"Exit code: {result.returncode}")
759
-
760
1298
  command_output = "\n".join(output_parts)
761
1299
 
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()
1300
+ try:
1301
+ prompt_id2 = _start_chat_async(
1302
+ codicent.base_url, token, codicent.verify_https,
1303
+ project, command_output, conversationId
1304
+ )
1305
+ with console.status("[dim]Sending output to AI...[/dim]", spinner="dots"):
1306
+ resp2 = _wait_for_ai_reply_with_delegation(
1307
+ codicent.base_url, token, codicent.verify_https,
1308
+ project, prompt_id2, handle_delegation
1309
+ )
1310
+ if resp2:
1311
+ conversationId = resp2["id"]
1312
+ console.print()
1313
+ console.print("[dim]AI response:[/dim]")
1314
+ console.print(Markdown(resp2["content"]), style="green")
1315
+ console.print()
1316
+ except Exception as e:
1317
+ logger.warning(f"Could not send shell output back to AI: {e}")
774
1318
 
775
1319
  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
1320
+ console.print("[red]Command timed out after 60 seconds[/red]")
785
1321
  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
1322
+ console.print(f"[red]Error: {e}[/red]")
795
1323
  else:
796
1324
  console.print("[dim]Skipped.[/dim]")
797
1325
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codicent-cli
3
- Version: 0.8.3
3
+ Version: 0.8.5
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.3',
12
+ version='0.8.5',
13
13
  author='Johan Isaksson',
14
14
  author_email='johan@izaxon.com',
15
15
  description='Command-line interface for the Codicent API',
@@ -102,7 +102,7 @@ def run_terminal_host(token: str, base_url: str, project: str, shell: str = None
102
102
  _set_pty_size(cols, rows)
103
103
 
104
104
  def on_open(ws):
105
- browser_url = f"{ws_base}/terminal/ws?sessionId={session_id}"
105
+ browser_url = f"{base_url.rstrip('/')}/terminal/ws?sessionId={session_id}"
106
106
  print(f"[codicent terminal] Connected. Shell is ready in the browser.")
107
107
  print(f"[codicent terminal] Browser connect: {browser_url}")
108
108
  # Send initial resize
@@ -202,7 +202,7 @@ def run_terminal_host(token: str, base_url: str, project: str, shell: str = None
202
202
  threading.Thread(target=_stdout_reader, daemon=True).start()
203
203
 
204
204
  def on_open(ws):
205
- browser_url = f"{ws_base}/terminal/ws?sessionId={session_id}"
205
+ browser_url = f"{base_url.rstrip('/')}/terminal/ws?sessionId={session_id}"
206
206
  print(f"[codicent terminal] Connected.")
207
207
  print(f"[codicent terminal] Browser connect: {browser_url}")
208
208
 
File without changes
File without changes
File without changes
File without changes
File without changes