seedcode-cli 7.1.0__py3-none-any.whl → 7.2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
seedcode/__init__.py CHANGED
@@ -6,7 +6,7 @@ Plant ideas. Grow code.
6
6
  # Single source of truth for the application version. Every consumer reads it:
7
7
  # the PyPI build (via [tool.hatch.version]), the CLI --version flag, the
8
8
  # Windows installer metadata, the GitHub release tag, and the WinGet manifest.
9
- __version__ = "7.1.0"
9
+ __version__ = "7.2.5"
10
10
  __author__ = "Al Shahriar Sowan"
11
11
  __publisher__ = "Eagox Studio"
12
12
  # Current author/company links, surfaced by /about and the README credits.
seedcode/app.py CHANGED
@@ -32,7 +32,12 @@ from .core.agent import AgentEngine, strip_tool_blocks
32
32
  from .core.chat import ChatEngine, ChatError
33
33
  from .core.lifecycle import LifecycleError, lifecycle
34
34
  from .core.models import AppConfig
35
- from .core.providers import PROVIDERS, provider_label, provider_ready
35
+ from .core.providers import (
36
+ PROVIDERS,
37
+ provider_label,
38
+ provider_ready,
39
+ sync_custom_providers,
40
+ )
36
41
  from .core.providers.freemodel import AUTO_MODEL
37
42
  from .memory import HistoryStore
38
43
  from .tools import PermissionManager, PermissionMode
@@ -310,6 +315,14 @@ class _TaskPresenter:
310
315
  return
311
316
  if kind == "error":
312
317
  self.ui.dim(f" ✖ {detail.splitlines()[0][:120]}")
318
+ elif kind == "switching_provider":
319
+ # A provider request failed and a healthy fallback took over; the
320
+ # task continues rather than restarting (v7.2.5).
321
+ self.ui.warning(f" Switching provider: {detail}")
322
+ if flow is not None:
323
+ flow.set_activity("Switching provider")
324
+ elif kind == "retry":
325
+ self.ui.dim(f" {detail}")
313
326
  elif kind == "limit":
314
327
  self.ui.warning(f"Assist stopped: {detail}")
315
328
  elif kind == "call" and flow is None:
@@ -384,10 +397,23 @@ class _TaskPresenter:
384
397
  self.ui.warning(detail)
385
398
  elif kind == "task_failed":
386
399
  self.ui.error(detail)
400
+ elif kind == "task_skipped":
401
+ # A skipped task is resolved, never verified work (v7.2.5).
402
+ self.ui.dim(f" – {detail}")
387
403
  elif kind == "final_verify":
388
404
  self.ui.dim(" final verification…")
389
405
  if flow is not None:
390
406
  flow.set_activity("Verifying the project (tests / build)")
407
+ elif kind == "reconnect":
408
+ # The connection dropped; the session is reconnecting and the task
409
+ # is preserved rather than failed (v7.2.5).
410
+ self.ui.warning(f" {detail}")
411
+ if flow is not None:
412
+ flow.set_activity("Reconnecting to the provider")
413
+ elif kind == "reconnected":
414
+ self.ui.dim(f" {detail}")
415
+ elif kind == "network_lost":
416
+ self.ui.warning(f"Network: {detail}")
391
417
  elif kind in ("retry", "continue"):
392
418
  self.ui.dim(f" {detail}")
393
419
  if flow is not None and session is not None:
@@ -586,7 +612,13 @@ def _handle_codemode(
586
612
  if flow is not None:
587
613
  flow.set_state("paused")
588
614
  flow.stop()
589
- ui.warning("Session paused — state saved. Send /resume to continue.")
615
+ # Surface the real reason (a network pause says so) but keep the
616
+ # actionable instruction identical for every pause.
617
+ reason = (session.reason or "").strip()
618
+ if reason and "connection" in reason.lower():
619
+ ui.warning(f"{reason}")
620
+ else:
621
+ ui.warning("Session paused — state saved. Send /resume to continue.")
590
622
  session_mod.set_current_session(session)
591
623
  return
592
624
 
@@ -620,6 +652,10 @@ def _report_session_summary(ui: UI, session) -> None:
620
652
  graph = session.graph
621
653
  done, total = graph.progress()
622
654
  ui.success(f"Project completed — {done}/{total} tasks verified")
655
+ skipped = graph.skipped_count()
656
+ if skipped:
657
+ # A skipped task is resolved, never counted as verified work.
658
+ ui.dim(f" • Skipped: {skipped} task(s) not needed")
623
659
  evidence = session.session_evidence
624
660
  if evidence.tests:
625
661
  # What matters is the final state: the last test run passed.
@@ -1007,6 +1043,9 @@ def run(ui: UI) -> None:
1007
1043
  interactive menu remains available via /exit for provider/model/settings.
1008
1044
  """
1009
1045
  config = load_config()
1046
+ # Register the saved custom providers before anything resolves a provider
1047
+ # id (v7.2.5): the registry must reflect config.json from the first turn.
1048
+ sync_custom_providers(config)
1010
1049
  set_active_theme(config.theme)
1011
1050
  ui.apply_theme(config.theme)
1012
1051
  ui.banner(config)
@@ -1017,8 +1056,15 @@ def run(ui: UI) -> None:
1017
1056
  config.is_configured(),
1018
1057
  )
1019
1058
 
1059
+ def presenter_on_event(kind: str, detail: str) -> None:
1060
+ """Surface provider retry/failover on the plain chat prompt (v7.2.5)."""
1061
+ if kind == "switching_provider":
1062
+ ui.warning(f"Switching provider: {detail}")
1063
+ elif kind == "retry":
1064
+ ui.dim(f" {detail}")
1065
+
1020
1066
  active_backend = config.provider
1021
- engine = ChatEngine(config)
1067
+ engine = ChatEngine(config, on_event=presenter_on_event)
1022
1068
  history = HistoryStore(provider_id=active_backend)
1023
1069
  chat_session = _make_chat_session(ui)
1024
1070
 
@@ -1031,7 +1077,7 @@ def run(ui: UI) -> None:
1031
1077
  if config.provider != active_backend:
1032
1078
  # Guided setup switched providers: rebuild the backend state.
1033
1079
  active_backend = config.provider
1034
- engine = ChatEngine(config)
1080
+ engine = ChatEngine(config, on_event=presenter_on_event)
1035
1081
  history = HistoryStore(provider_id=active_backend)
1036
1082
  _chat_loop(ui, config, engine, history, chat_session)
1037
1083
  else:
@@ -1048,7 +1094,7 @@ def run(ui: UI) -> None:
1048
1094
  # backend/context and the new provider's own history store.
1049
1095
  _log.info("backend switched: %s -> %s", active_backend, config.provider)
1050
1096
  active_backend = config.provider
1051
- engine = ChatEngine(config)
1097
+ engine = ChatEngine(config, on_event=presenter_on_event)
1052
1098
  history = HistoryStore(provider_id=active_backend)
1053
1099
 
1054
1100
  try:
seedcode/branding.py CHANGED
@@ -8,10 +8,11 @@ the exact palette so every surface renders identical art:
8
8
  bitmaps, the canonical ``assets/logo.png`` and the preview render
9
9
  * the installer wizard and the Windows taskbar/Explorer surfaces
10
10
 
11
- v6.2.5 note: the interactive CLI no longer renders any ASCII art. The mark
12
- is a packaging/desktop asset only (:mod:`seedcode.ui.dashboard` renders a
13
- borderless text header with no logo), so this module is not imported by the
14
- terminal UI at all.
11
+ v7.2.5 note: this module stays a packaging/desktop asset. The terminal UI
12
+ renders its own branding from :data:`seedcode.ui.dashboard.LOGO_LINES` (the
13
+ exact ANSI wordmark, with a text fallback on narrow or non-Unicode consoles),
14
+ so no terminal surface imports this module and the pixel-art mark never costs
15
+ anything at startup.
15
16
 
16
17
  Stdlib only (struct/zlib): safe to import from anywhere in the package,
17
18
  costs nothing at startup, and keeps the brand byte-reproducible.
@@ -11,12 +11,16 @@ switching behave identically.
11
11
  from __future__ import annotations
12
12
 
13
13
  from ..config import save_config
14
+ from ..core.models import CustomProviderConfig, valid_base_url
14
15
  from ..core.providers import (
15
16
  PROVIDERS,
16
17
  ModelInfo,
17
18
  Provider,
18
19
  ProviderError,
19
20
  get_provider,
21
+ is_custom_provider_id,
22
+ sync_custom_providers,
23
+ visible_provider_ids,
20
24
  )
21
25
  from ..core.providers.base import STATUS_CONNECTED, STATUS_OFFLINE
22
26
  from ..core.providers.freemodel import AUTO_MODEL
@@ -26,6 +30,9 @@ from ..ui.selector import Option, select
26
30
  from ..ui.textbox import read_text
27
31
  from . import CommandContext, CommandResult, command
28
32
 
33
+ #: Sentinel returned by the provider picker for "add a custom provider".
34
+ _ADD_CUSTOM = "__add_custom__"
35
+
29
36
 
30
37
  # --- provider selection ------------------------------------------------------
31
38
 
@@ -52,9 +59,12 @@ def _resolve_provider(text: str) -> Provider | None:
52
59
  _GROUP_BUILTIN = "Built-in · no API key needed"
53
60
  _GROUP_BYOK = "Your own API key"
54
61
  _GROUP_LOCAL = "Local · runs on this machine"
62
+ _GROUP_CUSTOM = "Custom · your own endpoint"
55
63
 
56
64
 
57
65
  def _provider_group(provider: Provider) -> str:
66
+ if is_custom_provider_id(provider.id):
67
+ return _GROUP_CUSTOM
58
68
  if provider.local:
59
69
  return _GROUP_LOCAL
60
70
  if provider.id == "default":
@@ -83,14 +93,31 @@ def _provider_key_column(provider: Provider, config) -> str:
83
93
  return "API key needed"
84
94
 
85
95
 
86
- def _provider_menu(ui, config) -> Provider | None:
87
- """Interactive provider selector: badge, backend, model, and key state."""
96
+ def _provider_model_column(config, provider_id: str) -> str:
97
+ """The provider's currently selected model, or an em dash."""
98
+ custom = config.custom_provider(provider_id)
99
+ if custom is not None:
100
+ model = custom.model
101
+ else:
102
+ entry = config.providers.get(provider_id)
103
+ model = entry.model if entry else ""
104
+ if model == AUTO_MODEL:
105
+ return "Auto"
106
+ return model or "—"
107
+
108
+
109
+ def _provider_menu(ui, config) -> str | None:
110
+ """Interactive provider selector: badge, backend, model, and key state.
111
+
112
+ Offers the v7.2.5 list — OpenRouter, Ollama and every saved custom
113
+ provider — plus an entry to add a new custom endpoint. A legacy built-in
114
+ appears only while the user is on it (backward compatibility).
115
+ """
88
116
  options = []
89
- for p in PROVIDERS.values():
90
- entry = config.providers.get(p.id)
91
- model = entry.model if entry and entry.model else "—"
92
- if model == AUTO_MODEL:
93
- model = "Auto"
117
+ for pid in visible_provider_ids(config):
118
+ p = PROVIDERS.get(pid)
119
+ if p is None:
120
+ continue
94
121
  options.append(
95
122
  Option(
96
123
  p.label,
@@ -98,12 +125,20 @@ def _provider_menu(ui, config) -> Provider | None:
98
125
  badge=badge_for_status(p.status),
99
126
  columns=(
100
127
  _provider_backend(p, config),
101
- model,
128
+ _provider_model_column(config, p.id),
102
129
  _provider_key_column(p, config),
103
130
  ),
104
131
  group=_provider_group(p),
105
132
  )
106
133
  )
134
+ options.append(
135
+ Option(
136
+ "Add a custom provider…",
137
+ value=_ADD_CUSTOM,
138
+ detail="any OpenAI-compatible API: name, base URL, key",
139
+ group=_GROUP_CUSTOM,
140
+ )
141
+ )
107
142
  chosen = select(
108
143
  options,
109
144
  title="Provider",
@@ -113,7 +148,7 @@ def _provider_menu(ui, config) -> Provider | None:
113
148
  if chosen is None:
114
149
  ui.dim("Cancelled.")
115
150
  return None
116
- return PROVIDERS[str(chosen)]
151
+ return str(chosen)
117
152
 
118
153
 
119
154
  def _collect_key(ui, config, provider: Provider, *, replacing: bool = False) -> bool:
@@ -154,14 +189,18 @@ def _ensure_ready(ui, config, provider: Provider) -> bool:
154
189
  Returns False only when the user cancels key entry. An unreachable
155
190
  key-less backend (local Ollama, or the built-in Default connection in a
156
191
  build that carries no credential) is reported with its own actionable
157
- message but is never fatal — the user may start Ollama or pick another
158
- provider later.
192
+ message but is never fatal — Ollama is auto-started (v7.2.5), and the
193
+ built-in Default connection can be replaced by another provider later.
159
194
  """
160
195
  if not provider.requires_key:
196
+ if provider.id == "ollama":
197
+ return _ensure_ollama(ui, config, provider)
198
+ # Use the provider's own status refresh so a build with no built-in
199
+ # credential reports "No API Key" (a setup state) instead of a false
200
+ # "Offline" (v7.2.5 pip-install fix).
161
201
  with ui.thinking(f"Checking {provider.label}"):
162
- reachable = provider.detect(config)
163
- provider.status = STATUS_CONNECTED if reachable else STATUS_OFFLINE
164
- if reachable:
202
+ status = provider.refresh_status(config)
203
+ if status == STATUS_CONNECTED:
165
204
  ui.success(f"{provider.label} is ready.")
166
205
  else:
167
206
  ui.warning(provider.unavailable_hint(config))
@@ -180,15 +219,30 @@ def select_provider(ui, config, target: str = "") -> bool:
180
219
  """Switch the active provider; returns True when the switch completed.
181
220
 
182
221
  Only ``active_provider`` changes — every provider keeps its own saved
183
- API key and model, so switching back restores them untouched.
222
+ API key and model, so switching back restores them untouched. Custom
223
+ providers are synchronised into the registry first so they resolve.
184
224
  """
225
+ sync_custom_providers(config)
185
226
  chosen: Provider | None = None
186
227
  if target:
187
228
  chosen = _resolve_provider(target)
229
+ if chosen is None:
230
+ entry = config.custom_provider(target)
231
+ if entry is not None:
232
+ chosen = get_provider(entry.id)
188
233
  if chosen is None:
189
234
  ui.warning(f"Unknown provider '{target}'.")
190
235
  if chosen is None:
191
- chosen = _provider_menu(ui, config)
236
+ picked = _provider_menu(ui, config)
237
+ if picked is None:
238
+ return False
239
+ if picked == _ADD_CUSTOM:
240
+ entry = _add_custom_flow(ui, config)
241
+ if entry is None:
242
+ return False
243
+ chosen = get_provider(entry.id)
244
+ else:
245
+ chosen = PROVIDERS.get(picked)
192
246
  if chosen is None:
193
247
  return False
194
248
 
@@ -198,6 +252,11 @@ def select_provider(ui, config, target: str = "") -> bool:
198
252
  config.provider = previous # cancelled key entry: keep the old backend
199
253
  return False
200
254
 
255
+ # An explicit selection clears any prior failure state, so a provider the
256
+ # user deliberately chose is tried again even if it had cooled down.
257
+ from ..core.providers import health as health_mod
258
+
259
+ health_mod.tracker().reset(chosen.id)
201
260
  save_config(config)
202
261
  ui.success(f"Provider set to {chosen.label}.")
203
262
  # The provider's own saved model is active again automatically.
@@ -208,6 +267,201 @@ def select_provider(ui, config, target: str = "") -> bool:
208
267
  return True
209
268
 
210
269
 
270
+ # --- Ollama auto-start -------------------------------------------------------
271
+
272
+
273
+ def _ensure_ollama(ui, config, provider: Provider) -> bool:
274
+ """Ensure the local Ollama server is running, starting it if needed.
275
+
276
+ Starting the server never fails the selection: if it cannot be started,
277
+ the user is told exactly what to do and may pick another provider.
278
+ """
279
+ from ..core.providers.ollama_start import ensure_running
280
+
281
+ if provider.detect(config):
282
+ provider.status = STATUS_CONNECTED
283
+ ui.success(f"{provider.label} is ready.")
284
+ return True
285
+
286
+ ui.info("Ollama server not detected — starting it…")
287
+ ok, message = ensure_running(config, on_status=lambda line: ui.dim(f" {line}"))
288
+ if ok:
289
+ provider.status = STATUS_CONNECTED
290
+ ui.success(message)
291
+ else:
292
+ provider.status = STATUS_OFFLINE
293
+ ui.warning(message)
294
+ return True
295
+
296
+
297
+ # --- custom providers --------------------------------------------------------
298
+
299
+
300
+ def custom_providers_menu(ui, config) -> None:
301
+ """Manage the saved custom providers: add, use, edit, test, reorder, delete."""
302
+ sync_custom_providers(config)
303
+ while True:
304
+ entries = config.ordered_custom_providers()
305
+ items = [MenuItem("Add a custom provider…", "add", badge="new")]
306
+ for entry in entries:
307
+ state = "enabled" if entry.enabled else "disabled"
308
+ items.append(
309
+ MenuItem(
310
+ entry.name,
311
+ f"pick:{entry.id}",
312
+ status=f"{entry.base_url} · {state} · {config.masked_key(entry.id)}",
313
+ )
314
+ )
315
+ items.append(MenuItem("Back", "back"))
316
+ choice = run_menu(
317
+ items,
318
+ title=f"Custom providers ({len(entries)})",
319
+ hint="↑↓ move Enter select Esc back",
320
+ )
321
+ if choice is None or choice == "back":
322
+ return
323
+ if choice == "add":
324
+ _add_custom_flow(ui, config)
325
+ continue
326
+ _custom_actions(ui, config, str(choice).split(":", 1)[1])
327
+
328
+
329
+ def _add_custom_flow(ui, config) -> CustomProviderConfig | None:
330
+ """Prompt for Name, Base URL, API Key (and optional model); save + test."""
331
+ ui.info("Add a custom provider — any OpenAI-compatible API.")
332
+ while True:
333
+ name = read_text("Name > ")
334
+ if name is None or not name.strip():
335
+ ui.dim("Cancelled — no provider added.")
336
+ return None
337
+ base_url = read_text("Base URL > ")
338
+ if base_url is None or not base_url.strip():
339
+ ui.dim("Cancelled — no provider added.")
340
+ return None
341
+ if not valid_base_url(base_url):
342
+ ui.error("Base URL must start with http:// or https://")
343
+ continue
344
+ key = read_text("API Key > ", password=True)
345
+ if key is None:
346
+ ui.dim("Cancelled — no provider added.")
347
+ return None
348
+ model = read_text("Model (optional) > ") or ""
349
+ try:
350
+ entry = config.add_custom_provider(name, base_url, key, model)
351
+ except ValueError as exc:
352
+ ui.error(str(exc))
353
+ continue
354
+ sync_custom_providers(config)
355
+ save_config(config)
356
+ ui.success(f"Saved custom provider '{entry.name}'.")
357
+ _test_connection(ui, config, entry.id)
358
+ return entry
359
+
360
+
361
+ def _edit_custom_flow(ui, config, provider_id: str) -> None:
362
+ """Edit a saved custom provider; an empty answer keeps the current value."""
363
+ entry = config.custom_provider(provider_id)
364
+ if entry is None:
365
+ return
366
+ ui.info(f"Editing '{entry.name}' — press Enter to keep the current value.")
367
+ name = read_text(f"Name [{entry.name}] > ")
368
+ base_url = read_text(f"Base URL [{entry.base_url}] > ")
369
+ key = read_text("API Key (keep current) > ", password=True)
370
+ model = read_text(f"Model [{entry.model or 'none'}] > ")
371
+ try:
372
+ config.update_custom_provider(
373
+ provider_id,
374
+ name=(name.strip() if name and name.strip() else None),
375
+ base_url=(base_url.strip() if base_url and base_url.strip() else None),
376
+ api_key=(key.strip() if key and key.strip() else None),
377
+ model=(model.strip() if model and model.strip() else None),
378
+ )
379
+ except ValueError as exc:
380
+ ui.error(str(exc))
381
+ return
382
+ save_config(config)
383
+ sync_custom_providers(config)
384
+ ui.success("Saved.")
385
+
386
+
387
+ def _test_connection(ui, config, provider_id: str) -> bool:
388
+ """Validate a provider's own credential with a real request."""
389
+ from ..core.providers import health as health_mod
390
+
391
+ try:
392
+ provider = get_provider(provider_id)
393
+ except ProviderError as exc:
394
+ ui.error(str(exc))
395
+ return False
396
+ with ui.thinking(f"Testing {provider.label}"):
397
+ result = provider.validate_key(config.get_api_key(provider_id))
398
+ if result.ok:
399
+ provider.status = STATUS_CONNECTED
400
+ health_mod.tracker().record_success(provider_id)
401
+ ui.success(result.message)
402
+ else:
403
+ provider.status = STATUS_OFFLINE
404
+ health_mod.tracker().record_failure(provider_id, reason=result.message)
405
+ ui.warning(result.message)
406
+ return result.ok
407
+
408
+
409
+ def _custom_actions(ui, config, provider_id: str) -> None:
410
+ """Per-provider actions: use, edit, test, enable/disable, reorder, delete."""
411
+ while True:
412
+ entry = config.custom_provider(provider_id)
413
+ if entry is None:
414
+ return
415
+ state = "enabled" if entry.enabled else "disabled"
416
+ choice = run_menu(
417
+ [
418
+ MenuItem("Use this provider", "use"),
419
+ MenuItem("Edit", "edit"),
420
+ MenuItem("Test connection", "test"),
421
+ MenuItem("Disable" if entry.enabled else "Enable", "toggle"),
422
+ MenuItem("Move up", "up"),
423
+ MenuItem("Move down", "down"),
424
+ MenuItem("Delete", "delete"),
425
+ MenuItem("Back", "back"),
426
+ ],
427
+ title=f"{entry.name} — {state}",
428
+ hint="↑↓ move Enter select Esc back",
429
+ )
430
+ if choice is None or choice == "back":
431
+ return
432
+ if choice == "use":
433
+ select_provider(ui, config, entry.id)
434
+ return
435
+ if choice == "edit":
436
+ _edit_custom_flow(ui, config, provider_id)
437
+ elif choice == "test":
438
+ _test_connection(ui, config, provider_id)
439
+ elif choice == "toggle":
440
+ config.set_custom_enabled(provider_id, not entry.enabled)
441
+ save_config(config)
442
+ sync_custom_providers(config)
443
+ ui.success(f"'{entry.name}' {'disabled' if entry.enabled else 'enabled'}.")
444
+ elif choice in ("up", "down"):
445
+ moved = config.move_custom_provider(
446
+ provider_id, -1 if choice == "up" else 1
447
+ )
448
+ save_config(config)
449
+ sync_custom_providers(config)
450
+ ui.dim("Reordered." if moved else "Already at the edge.")
451
+ elif choice == "delete":
452
+ from ..ui.dialog import confirm_dialog
453
+
454
+ name = entry.name
455
+ if confirm_dialog(
456
+ f"Delete '{name}'?", yes_label="Delete", no_label="Keep", danger=True
457
+ ):
458
+ config.remove_custom_provider(provider_id)
459
+ save_config(config)
460
+ sync_custom_providers(config)
461
+ ui.success(f"Deleted '{name}'.")
462
+ return
463
+
464
+
211
465
  # --- model selection ---------------------------------------------------------
212
466
 
213
467
 
@@ -387,14 +641,23 @@ def select_model(ui, config, target: str = "") -> None:
387
641
 
388
642
  @command(
389
643
  "provider",
390
- "Select the active provider "
391
- "(Default, OpenRouter, FreeModel Claude, FreeModel Codex, AeroLink, Ollama)",
644
+ "Select the active provider (OpenRouter, Ollama, or a saved custom provider)",
392
645
  )
393
646
  def _provider_cmd(ctx: CommandContext, arg: str) -> CommandResult:
394
647
  select_provider(ctx.ui, ctx.config, arg.strip())
395
648
  return CommandResult()
396
649
 
397
650
 
651
+ @command(
652
+ "custom",
653
+ "Manage custom providers (add, edit, test, reorder, enable, delete)",
654
+ aliases=("custom-providers", "providers"),
655
+ )
656
+ def _custom_cmd(ctx: CommandContext, arg: str) -> CommandResult:
657
+ custom_providers_menu(ctx.ui, ctx.config)
658
+ return CommandResult()
659
+
660
+
398
661
  @command("model", "Browse and select a model for the active provider", aliases=("show",))
399
662
  def _model_cmd(ctx: CommandContext, arg: str) -> CommandResult:
400
663
  target = arg.strip()