seedcode-cli 6.1.5__py3-none-any.whl → 6.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.
Files changed (56) hide show
  1. seedcode/__init__.py +1 -1
  2. seedcode/_default_key_template.py +16 -0
  3. seedcode/app.py +171 -21
  4. seedcode/apps/discovery.py +37 -2
  5. seedcode/assets/logo.png +0 -0
  6. seedcode/branding.py +95 -0
  7. seedcode/cli.py +11 -3
  8. seedcode/codemode.py +363 -0
  9. seedcode/codemode_state.py +89 -0
  10. seedcode/commands/__init__.py +4 -2
  11. seedcode/commands/agent.py +7 -2
  12. seedcode/commands/assist.py +2 -1
  13. seedcode/commands/chat.py +46 -0
  14. seedcode/commands/codemode.py +103 -0
  15. seedcode/commands/mode.py +50 -0
  16. seedcode/commands/provider.py +52 -16
  17. seedcode/commands/status.py +95 -0
  18. seedcode/computer/controller.py +32 -3
  19. seedcode/computer/engine.py +3 -0
  20. seedcode/computer/keyboard.py +9 -1
  21. seedcode/computer/mouse.py +15 -6
  22. seedcode/computer/state.py +23 -1
  23. seedcode/config/__init__.py +20 -2
  24. seedcode/config/defaults.py +29 -2
  25. seedcode/config/manager.py +185 -3
  26. seedcode/core/agent.py +156 -12
  27. seedcode/core/chat.py +36 -4
  28. seedcode/core/http.py +171 -0
  29. seedcode/core/models.py +46 -14
  30. seedcode/core/providers/__init__.py +34 -7
  31. seedcode/core/providers/aerolink.py +4 -3
  32. seedcode/core/providers/base.py +24 -2
  33. seedcode/core/providers/default.py +157 -0
  34. seedcode/core/providers/freemodel.py +6 -5
  35. seedcode/core/providers/ollama.py +14 -5
  36. seedcode/core/providers/openrouter.py +83 -31
  37. seedcode/default_api.py +156 -0
  38. seedcode/defaults.py +37 -0
  39. seedcode/tools/base.py +16 -1
  40. seedcode/tools/desktop.py +24 -0
  41. seedcode/tools/filesystem.py +3 -1
  42. seedcode/tools/terminal.py +30 -5
  43. seedcode/ui/__init__.py +30 -12
  44. seedcode/ui/dashboard.py +121 -153
  45. seedcode/ui/menu.py +4 -0
  46. seedcode/ui/reference.py +46 -0
  47. seedcode/ui/renderer.py +27 -1
  48. seedcode/ui/selector.py +24 -0
  49. seedcode/utils/terminal_env.py +225 -0
  50. seedcode_cli-6.2.5.dist-info/METADATA +457 -0
  51. {seedcode_cli-6.1.5.dist-info → seedcode_cli-6.2.5.dist-info}/RECORD +54 -40
  52. {seedcode_cli-6.1.5.dist-info → seedcode_cli-6.2.5.dist-info}/WHEEL +1 -1
  53. seedcode/ui/banner.py +0 -78
  54. seedcode_cli-6.1.5.dist-info/METADATA +0 -368
  55. {seedcode_cli-6.1.5.dist-info → seedcode_cli-6.2.5.dist-info}/entry_points.txt +0 -0
  56. {seedcode_cli-6.1.5.dist-info → seedcode_cli-6.2.5.dist-info}/licenses/LICENSE +0 -0
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__ = "6.1.5"
9
+ __version__ = "6.2.5"
10
10
  __author__ = "Al shahriar sowan"
11
11
  __publisher__ = "Eagox Studio"
12
12
 
@@ -0,0 +1,16 @@
1
+ """Embedded built-in credential for the Default provider (v6.2.5 artifact).
2
+
3
+ This file is NOT committed to source control. The release build generates
4
+ ``seedcode/_default_key.py`` from the local ``.env`` (see
5
+ ``scripts/windows/embed_default_key.py``). Source checkouts without that
6
+ generated file fall back to the empty default below, so behavior is
7
+ unchanged for developers and CI.
8
+
9
+ The credential belongs to the built-in ``Default`` provider and is resolved
10
+ per request by :mod:`seedcode.core.providers.default`; it is never copied
11
+ into another provider's stored configuration.
12
+ """
13
+
14
+ DEFAULT_OPENROUTER_API_KEY = ""
15
+ DEFAULT_API_ENABLED = False
16
+ DEFAULT_API_NOTE = "embedded default key unavailable (development checkout)"
seedcode/app.py CHANGED
@@ -14,9 +14,12 @@ Ctrl+L clear screen.
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ import sys
18
+
17
19
  from prompt_toolkit import PromptSession
18
20
  from prompt_toolkit.key_binding import KeyBindings
19
21
 
22
+ from . import __version__
20
23
  from .commands import CommandContext, dispatch, is_command
21
24
  from .commands.about import show_about
22
25
  from .commands.help import show_shortcuts
@@ -29,13 +32,14 @@ from .core.agent import AgentEngine, strip_tool_blocks
29
32
  from .core.chat import ChatEngine, ChatError
30
33
  from .core.lifecycle import lifecycle
31
34
  from .core.models import AppConfig
32
- from .core.providers import PROVIDERS, provider_label
35
+ from .core.providers import PROVIDERS, provider_label, provider_ready
33
36
  from .core.providers.freemodel import AUTO_MODEL
34
37
  from .memory import HistoryStore
35
38
  from .tools import PermissionManager, PermissionMode
36
39
  from .ui import UI
37
40
  from .ui.badges import badge_for_status
38
41
  from .ui.menu import MenuItem, run_menu
42
+ from .ui.reference import CONTROLS_HINT, INPUT_HINT
39
43
  from .ui.textbox import prompt_label
40
44
  from .ui.theme import pt_style, set_active_theme
41
45
  from .utils.logger import get_logger
@@ -52,13 +56,17 @@ _KEY_ACTIONS = {
52
56
  }
53
57
 
54
58
 
55
- def _exit_application(reason: str) -> None:
59
+ def _exit_application(ui: "UI", reason: str) -> None:
56
60
  """The single, explicit application-exit decision.
57
61
 
58
62
  Called only from unambiguous user actions (the menu's Exit item, leaving
59
63
  the menu with Esc/Ctrl+C/Ctrl+D). Marks the lifecycle, enters SHUTDOWN
60
64
  (running teardown hooks), and returns; :func:`run` then unwinds and
61
65
  ``main`` finishes normally. Task completion and errors never reach here.
66
+
67
+ ``ui`` is passed in explicitly by :func:`run` — this module has no global
68
+ UI instance, and referencing one from here was the root cause of the
69
+ ``NameError: name 'ui' is not defined`` users saw on Exit.
62
70
  """
63
71
  lc = lifecycle()
64
72
  lc.request_exit(reason)
@@ -98,9 +106,14 @@ def _release_desktop_resources() -> None:
98
106
 
99
107
 
100
108
  def _provider_status(config: AppConfig) -> str:
101
- """Menu status line: the active provider, or 'Not Configured'."""
102
- ready = config.provider == "ollama" or bool(config.get_api_key().strip())
103
- return provider_label(config.provider) if ready else "Not Configured"
109
+ """Menu status line: the active provider, or 'Not Configured'.
110
+
111
+ Uses the shared provider-ready rule, so the menu never disagrees with the
112
+ dashboard: Default and Ollama need no key, everyone else needs their own.
113
+ """
114
+ if provider_ready(config.provider, config):
115
+ return provider_label(config.provider)
116
+ return "Not Configured"
104
117
 
105
118
 
106
119
  def _model_status(config: AppConfig) -> str:
@@ -120,27 +133,75 @@ def _key_status(config: AppConfig) -> str:
120
133
  return config.masked_key()
121
134
 
122
135
 
136
+ def _mode_status(config: AppConfig) -> str:
137
+ """Menu status for the Code Mode item (ON only when really active)."""
138
+ try:
139
+ from .codemode_state import codemode_state
140
+
141
+ if codemode_state().enabled:
142
+ return "ON"
143
+ except Exception:
144
+ pass
145
+ return "ON" if config.agent_mode else "OFF"
146
+
147
+
123
148
  def _main_menu(config: AppConfig):
124
- """The interactive main menu; returns an action id or None (exit)."""
149
+ """The interactive main menu; returns an action id or None (exit).
150
+
151
+ v6.2.5 reference layout: the six mode/setup actions carry Ctrl+1..Ctrl+6
152
+ shortcuts that execute the real handlers (no decorative items), followed
153
+ by the chat/setup actions kept from earlier releases.
154
+ """
125
155
  provider = PROVIDERS.get(config.provider)
126
156
  badge = badge_for_status(provider.status if provider is not None else "")
127
157
  return run_menu(
128
158
  [
159
+ MenuItem("Code Mode", "codemode", status=_mode_status(config), shortcut="1"),
160
+ MenuItem("Agent Mode", "agent", shortcut="2"),
161
+ MenuItem("Assist Mode", "assist", shortcut="3"),
162
+ MenuItem("Project Memory", "memory", shortcut="4"),
163
+ MenuItem("Settings", "settings", shortcut="5"),
164
+ MenuItem("Exit", "exit", shortcut="6"),
129
165
  MenuItem("Start Chat", "chat", status=_model_status(config), badge=badge),
130
166
  MenuItem("Provider", "provider", status=_provider_status(config)),
131
167
  MenuItem("API Key", "apikey", status=_key_status(config)),
132
168
  MenuItem("Model", "model", status=_model_status(config)),
133
- MenuItem("Settings", "settings"),
134
169
  MenuItem("Theme", "theme", status=config.theme),
135
170
  MenuItem("About", "about"),
136
- MenuItem("Exit", "exit"),
137
171
  ],
138
- title="Seed Code",
139
- hint="↑↓ move type to filter Enter select Esc exit",
172
+ title=f"Seed Code v{__version__}",
173
+ hint=CONTROLS_HINT,
140
174
  initial="chat",
141
175
  )
142
176
 
143
177
 
178
+ def _dispatch_command(ui: UI, config: AppConfig, engine, command: str) -> None:
179
+ """Run a slash command from a menu action through the real router."""
180
+ dispatch(CommandContext(ui=ui, config=config, engine=engine), command)
181
+
182
+
183
+ def _report_command_error(ui: UI, exc: Exception) -> None:
184
+ """Render a failed command/action without leaking a raw traceback.
185
+
186
+ A transport failure becomes one actionable ``[Network Error]`` line, mapped
187
+ by :mod:`seedcode.core.http` (which never includes request headers, so an
188
+ API key can never surface here). Anything else keeps its own message; the
189
+ full traceback is already written to the log file.
190
+ """
191
+ try:
192
+ import httpx
193
+
194
+ from .core.http import friendly_error
195
+
196
+ if isinstance(exc, httpx.HTTPError):
197
+ ui.error(f"[Network Error] {friendly_error(exc)}")
198
+ ui.dim("Action: check your network connection or provider configuration.")
199
+ return
200
+ except Exception:
201
+ pass # error reporting must never itself raise
202
+ ui.error(f"[Command Error] {exc}")
203
+
204
+
144
205
  def _guided_setup(ui: UI, config: AppConfig) -> bool:
145
206
  """Provider -> API key -> validate -> fetch models -> select -> save.
146
207
 
@@ -227,6 +288,21 @@ def _handle_agent(ui: UI, agent: AgentEngine, history: HistoryStore, text: str)
227
288
  else:
228
289
  ui.dim("(no response)")
229
290
  history.save(agent.transcript)
291
+ # v6.2.0: Code Mode sessions leave a compact summary in .seedcode/sessions.
292
+ try:
293
+ from . import codemode_state as _cms
294
+
295
+ state = _cms.codemode_state()
296
+ if state.enabled and state.store is not None:
297
+ state.store.save_session_summary({
298
+ "goal": text[:200],
299
+ "outcome": (final.strip() or "(no response)")[:400],
300
+ "tool_calls": sum(
301
+ 1 for m in agent.messages if m.role == "tool"
302
+ ),
303
+ })
304
+ except Exception:
305
+ pass
230
306
 
231
307
 
232
308
  def _make_agent(ui: UI, config: AppConfig) -> AgentEngine:
@@ -324,20 +400,25 @@ def _chat_loop(
324
400
  config: AppConfig,
325
401
  engine: ChatEngine,
326
402
  history: HistoryStore,
327
- session: PromptSession,
403
+ session: PromptSession | _PlainSession,
328
404
  ) -> None:
329
405
  """Interactive chat until /exit (returns to the main menu)."""
330
406
  ctx = CommandContext(ui=ui, config=config, engine=engine)
331
- ui.dim(
332
- "Type /help for commands, /exit for the menu — "
333
- "Ctrl+K palette, Ctrl+P files, Ctrl+/ shortcuts."
334
- )
407
+ ui.dim(INPUT_HINT)
335
408
 
336
409
  # The Assist engine is built lazily on the first assist-mode turn and
337
410
  # rebuilt when the permission or desktop mode changes (its system
338
411
  # prompt and permission gates reflect both).
339
412
  agent: AgentEngine | None = None
340
413
  agent_perm = config.permission_mode
414
+ # v6.2.0: track Code Mode so a /codemode toggle rebuilds the agent with
415
+ # workspace context (the engine is constructed lazily per turn).
416
+ try:
417
+ from . import codemode_state as _cms
418
+
419
+ agent_codemode = _cms.enabled
420
+ except Exception:
421
+ agent_codemode = False
341
422
 
342
423
  while True:
343
424
  try:
@@ -371,7 +452,7 @@ def _chat_loop(
371
452
  continue
372
453
  except Exception as exc: # a broken command must not kill the REPL
373
454
  _log.exception("command failed: %s", text.split()[0])
374
- ui.error(f"Command failed: {exc}")
455
+ _report_command_error(ui, exc)
375
456
  continue
376
457
  if result.should_exit:
377
458
  return
@@ -389,14 +470,75 @@ def _chat_loop(
389
470
  # prompts again. A task can never end the app.
390
471
  with lifecycle().task_span():
391
472
  if config.agent_mode:
392
- if agent is None or agent_perm != config.permission_mode:
473
+ try:
474
+ from . import codemode_state as _cms
475
+
476
+ codemode_now = _cms.enabled
477
+ except Exception:
478
+ codemode_now = False
479
+ if (
480
+ agent is None
481
+ or agent_perm != config.permission_mode
482
+ or agent_codemode != codemode_now
483
+ ):
393
484
  agent = _make_agent(ui, config)
394
485
  agent_perm = config.permission_mode
486
+ agent_codemode = codemode_now
395
487
  _handle_agent(ui, agent, history, text)
396
488
  else:
397
489
  _handle_chat(ui, engine, history, text)
398
490
 
399
491
 
492
+ def _interactive() -> bool:
493
+ """True when a real console is attached (mirrors ``ui.selector._interactive``).
494
+
495
+ prompt_toolkit needs a console/PTY it can drive. Piped, redirected, or
496
+ console-less hosts (CI, ``seedcode < file``, some Git Bash/MSYS sessions)
497
+ cannot provide one, and constructing a prompt there raises instead of
498
+ degrading — so every interactive entry point checks this first.
499
+ """
500
+ try:
501
+ return sys.stdin.isatty() and sys.stdout.isatty()
502
+ except (AttributeError, ValueError):
503
+ return False
504
+
505
+
506
+ class _PlainSession:
507
+ """Line-based stand-in for :class:`PromptSession` on non-console hosts.
508
+
509
+ The chat loop only needs ``prompt()``, so this shim keeps Seed Code usable
510
+ where prompt_toolkit cannot start. Reading raises ``EOFError`` /
511
+ ``KeyboardInterrupt`` exactly like the real session, so the loop's existing
512
+ Ctrl+C / Ctrl+D handling is unchanged.
513
+ """
514
+
515
+ def prompt(self, message=None, **_kwargs) -> str:
516
+ return input(_plain_text(message) or "> ")
517
+
518
+
519
+ def _plain_text(message) -> str:
520
+ """Literal text of a prompt message (a prompt_toolkit ``FormattedText``)."""
521
+ if isinstance(message, str):
522
+ return message
523
+ try:
524
+ return "".join(fragment[1] for fragment in message)
525
+ except Exception:
526
+ return ""
527
+
528
+
529
+ def _make_chat_session(ui: UI):
530
+ """A prompt_toolkit session when a console exists, else the plain shim."""
531
+ if not _interactive():
532
+ return _PlainSession()
533
+ try:
534
+ return _build_chat_session(ui)
535
+ except Exception:
536
+ # A console that exists but still cannot be driven (e.g. a MSYS/mintty
537
+ # TERM against a non-Windows console). Never fatal — degrade instead.
538
+ _log.warning("prompt_toolkit unavailable; using line-based input")
539
+ return _PlainSession()
540
+
541
+
400
542
  def _build_chat_session(ui: UI) -> PromptSession:
401
543
  """The chat PromptSession with the global shortcut bindings attached."""
402
544
  kb = KeyBindings()
@@ -442,7 +584,7 @@ def run(ui: UI) -> None:
442
584
  active_backend = config.provider
443
585
  engine = ChatEngine(config)
444
586
  history = HistoryStore(provider_id=active_backend)
445
- chat_session = _build_chat_session(ui)
587
+ chat_session = _make_chat_session(ui)
446
588
 
447
589
  # Best-effort teardown for the one legitimate shutdown path.
448
590
  lifecycle().on_shutdown(_release_desktop_resources)
@@ -477,7 +619,7 @@ def run(ui: UI) -> None:
477
619
  choice = _main_menu(config)
478
620
  except (KeyboardInterrupt, EOFError):
479
621
  # The user explicitly left the menu — the app-exit decision.
480
- _exit_application("menu interrupt")
622
+ _exit_application(ui, "menu interrupt")
481
623
  return
482
624
 
483
625
  try:
@@ -492,6 +634,14 @@ def run(ui: UI) -> None:
492
634
  apikey_menu(ui, config)
493
635
  elif choice == "model":
494
636
  select_model(ui, config)
637
+ elif choice == "codemode":
638
+ _dispatch_command(ui, config, engine, "/codemode on")
639
+ elif choice == "agent":
640
+ _dispatch_command(ui, config, engine, "/agent on")
641
+ elif choice == "assist":
642
+ _dispatch_command(ui, config, engine, "/assist on")
643
+ elif choice == "memory":
644
+ _dispatch_command(ui, config, engine, "/codemode status")
495
645
  elif choice == "settings":
496
646
  settings_menu(ui, config)
497
647
  elif choice == "theme":
@@ -499,10 +649,10 @@ def run(ui: UI) -> None:
499
649
  elif choice == "about":
500
650
  show_about(ui, config)
501
651
  elif choice in ("exit", None):
502
- _exit_application("menu exit")
652
+ _exit_application(ui, "menu exit")
503
653
  return
504
654
  except (KeyboardInterrupt, EOFError):
505
655
  ui.dim("Cancelled.")
506
656
  except Exception as exc: # menu actions must never crash the app
507
657
  _log.exception("menu action failed: %s", choice)
508
- ui.error(f"Something went wrong: {exc}")
658
+ _report_command_error(ui, exc)
@@ -19,7 +19,8 @@ from __future__ import annotations
19
19
 
20
20
  import os
21
21
  import shutil
22
- from dataclasses import dataclass, field
22
+ import time
23
+ from dataclasses import dataclass, field, replace
23
24
  from pathlib import Path
24
25
  from typing import Any
25
26
 
@@ -50,7 +51,32 @@ _START_MENU_DIRS = (
50
51
  )
51
52
 
52
53
 
54
+ # --- v6.2.0 discovery caches ----------------------------------------------------
55
+ # Start Menu scans (rglob over two trees) and the registry walks (App Paths +
56
+ # uninstall keys) are the slow part of discovery; installed software changes
57
+ # on the scale of minutes, not milliseconds, so caching them briefly is safe.
58
+ # Entries are copied on return so callers (find_app resolves the exe on an
59
+ # uninstall entry) can never mutate the cached record.
60
+ _START_MENU_TTL_S = 5.0
61
+ _REGISTRY_TTL_S = 30.0
62
+ _start_menu_cache: tuple[float, list[Path]] | None = None
63
+ _app_paths_cache: tuple[float, dict[str, AppInfo]] | None = None
64
+ _uninstall_cache: tuple[float, dict[str, AppInfo]] | None = None
65
+
66
+
67
+ def _reset_discovery_cache() -> None:
68
+ """Drop all discovery caches (tests, session teardown)."""
69
+ global _start_menu_cache, _app_paths_cache, _uninstall_cache
70
+ _start_menu_cache = None
71
+ _app_paths_cache = None
72
+ _uninstall_cache = None
73
+
74
+
53
75
  def _iter_start_menu() -> list[Path]:
76
+ global _start_menu_cache
77
+ now = time.monotonic()
78
+ if _start_menu_cache is not None and now - _start_menu_cache[0] < _START_MENU_TTL_S:
79
+ return list(_start_menu_cache[1])
54
80
  shortcuts: list[Path] = []
55
81
  for base in _START_MENU_DIRS:
56
82
  try:
@@ -58,7 +84,8 @@ def _iter_start_menu() -> list[Path]:
58
84
  shortcuts.extend(base.rglob("*.lnk"))
59
85
  except OSError:
60
86
  continue
61
- return shortcuts
87
+ _start_menu_cache = (now, shortcuts)
88
+ return list(shortcuts)
62
89
 
63
90
 
64
91
  def _shortcut_display_name(path: Path) -> str:
@@ -76,6 +103,9 @@ def _match_name(haystack: str, needle: str) -> bool:
76
103
  # --- source 2: App Paths -----------------------------------------------------------
77
104
  def _app_paths() -> dict[str, AppInfo]:
78
105
  """``App Paths`` registrations: exe name -> full path (best-effort)."""
106
+ global _app_paths_cache
107
+ if _app_paths_cache is not None and time.monotonic() - _app_paths_cache[0] < _REGISTRY_TTL_S:
108
+ return {k: replace(v) for k, v in _app_paths_cache[1].items()}
79
109
  found: dict[str, AppInfo] = {}
80
110
  if os.name != "nt":
81
111
  return found
@@ -108,12 +138,16 @@ def _app_paths() -> dict[str, AppInfo]:
108
138
  )
109
139
  except ImportError:
110
140
  pass
141
+ _app_paths_cache = (time.monotonic(), found)
111
142
  return found
112
143
 
113
144
 
114
145
  # --- source 3: uninstall registrations ----------------------------------------------
115
146
  def _uninstall_apps() -> dict[str, AppInfo]:
116
147
  """Display-name/install-location entries from the uninstall keys."""
148
+ global _uninstall_cache
149
+ if _uninstall_cache is not None and time.monotonic() - _uninstall_cache[0] < _REGISTRY_TTL_S:
150
+ return {k: replace(v) for k, v in _uninstall_cache[1].items()}
117
151
  found: dict[str, AppInfo] = {}
118
152
  if os.name != "nt":
119
153
  return found
@@ -158,6 +192,7 @@ def _uninstall_apps() -> dict[str, AppInfo]:
158
192
  found.setdefault(name.lower(), entry)
159
193
  except ImportError:
160
194
  pass
195
+ _uninstall_cache = (time.monotonic(), found)
161
196
  return found
162
197
 
163
198
 
Binary file
seedcode/branding.py ADDED
@@ -0,0 +1,95 @@
1
+ """Canonical Seed Code brand artwork — single source of truth.
2
+
3
+ The official Seed Code mark is the 16x16 pixel-art "sprouting seed" on a
4
+ dark rounded tile (the Seed Green identity). This module owns the grid and
5
+ the exact palette so every surface renders identical art:
6
+
7
+ * ``scripts/windows/build_assets.py`` — ``seedcode.ico``, the Inno wizard
8
+ bitmaps, the canonical ``assets/logo.png`` and the preview render
9
+ * the installer wizard and the Windows taskbar/Explorer surfaces
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.
15
+
16
+ Stdlib only (struct/zlib): safe to import from anywhere in the package,
17
+ costs nothing at startup, and keeps the brand byte-reproducible.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import struct
23
+ import zlib
24
+
25
+ GRID = 16 # the mark is authored on a 16x16 grid
26
+
27
+ # --- palette (mirrors seedcode/ui/theme.py) ---------------------------------
28
+ ACCENT = (123, 237, 159, 255) # Soft Green — leaf highlight
29
+ PRIMARY = (46, 204, 113, 255) # Seed Green — leaf body
30
+ DARK = (27, 122, 67, 255) # seed body
31
+ TILE_BG = (13, 17, 23, 255) # dark rounded tile behind the mark
32
+ CLEAR = (0, 0, 0, 0) # outside the rounded tile (transparent)
33
+
34
+ # --- the mark: a sprouting seed, 16x16 --------------------------------------
35
+ ART: list[str] = [
36
+ "................",
37
+ "................",
38
+ "...LL.....GG....",
39
+ "..LLLL...GGGG...",
40
+ ".LLLLLL.GGGGG...",
41
+ ".LLLLLL.GGGGGG..",
42
+ "..LLLLL.GGGGG...",
43
+ "...LLLL.GGGG....",
44
+ ".....LL.GG......",
45
+ "......LGG.......",
46
+ ".......G........",
47
+ ".......G........",
48
+ "......DDD.......",
49
+ ".....DDDDD......",
50
+ ".....DDDDD......",
51
+ "......DDD.......",
52
+ ]
53
+ INK = {"L": ACCENT, "G": PRIMARY, "D": DARK}
54
+
55
+ Pixel = tuple[int, int, int, int]
56
+
57
+
58
+ def logo_pixels(size: int = GRID) -> list[list[Pixel]]:
59
+ """Render the mark at ``size`` px: rounded dark tile + scaled pixel art."""
60
+ radius = size * 3 // 16
61
+ grid = [[CLEAR] * size for _ in range(size)]
62
+ for y in range(size):
63
+ for x in range(size):
64
+ # Rounded-rect clip: inside unless outside a corner circle.
65
+ cx = radius if x < radius else (size - 1 - radius if x >= size - radius else x)
66
+ cy = radius if y < radius else (size - 1 - radius if y >= size - radius else y)
67
+ if (x - cx) ** 2 + (y - cy) ** 2 > radius * radius and (
68
+ (x < radius or x >= size - radius) and (y < radius or y >= size - radius)
69
+ ):
70
+ continue
71
+ cell = ART[y * GRID // size][x * GRID // size]
72
+ grid[y][x] = INK.get(cell, TILE_BG)
73
+ return grid
74
+
75
+
76
+ def render_logo_png(size: int = 256) -> bytes:
77
+ """Encode the mark at ``size`` px as an RGBA PNG (the canonical asset)."""
78
+ grid = logo_pixels(size)
79
+ h = w = size
80
+
81
+ def chunk(tag: bytes, data: bytes) -> bytes:
82
+ return (
83
+ struct.pack(">I", len(data)) + tag + data
84
+ + struct.pack(">I", zlib.crc32(tag + data))
85
+ )
86
+
87
+ raw = b"".join(
88
+ b"\x00" + b"".join(bytes(px) for px in row) for row in grid
89
+ )
90
+ return (
91
+ b"\x89PNG\r\n\x1a\n"
92
+ + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0))
93
+ + chunk(b"IDAT", zlib.compress(raw, 9))
94
+ + chunk(b"IEND", b"")
95
+ )
seedcode/cli.py CHANGED
@@ -51,7 +51,8 @@ def main() -> None:
51
51
  if len(sys.argv) > 1 and sys.argv[1] in ("--version", "-V"):
52
52
  from . import __version__
53
53
 
54
- print(f"Seed Code v{__version__}")
54
+ # Installers verify the install by parsing this exact line.
55
+ print(f"Seed Code CLI {__version__}")
55
56
  return
56
57
  if len(sys.argv) > 1 and sys.argv[1] in ("--help", "-h"):
57
58
  print(_HELP)
@@ -65,18 +66,25 @@ def main() -> None:
65
66
  log = get_logger("cli")
66
67
 
67
68
  from . import __version__
69
+ from .utils.terminal_env import detect_terminal, workspace_root
68
70
 
71
+ # Detect the terminal host/shell once (VS Code, Windows Terminal, Git Bash
72
+ # or unknown) so a support log says exactly where this session ran. The
73
+ # workspace root is the launch directory and is never changed below.
74
+ terminal = detect_terminal()
69
75
  log.info(
70
- "Seed Code v%s starting (python %s on %s)",
76
+ "Seed Code v%s starting (python %s on %s); terminal %s; cwd=%s",
71
77
  __version__,
72
78
  sys.version.split()[0],
73
79
  sys.platform,
80
+ terminal.describe(),
81
+ workspace_root(),
74
82
  )
75
83
 
76
84
  from .app import run
77
85
  from .ui import UI
78
86
 
79
- ui = UI()
87
+ ui = UI(plain=terminal.plain)
80
88
  try:
81
89
  run(ui)
82
90
  log.info("Clean exit.")