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.
- seedcode/__init__.py +1 -1
- seedcode/_default_key_template.py +16 -0
- seedcode/app.py +171 -21
- seedcode/apps/discovery.py +37 -2
- seedcode/assets/logo.png +0 -0
- seedcode/branding.py +95 -0
- seedcode/cli.py +11 -3
- seedcode/codemode.py +363 -0
- seedcode/codemode_state.py +89 -0
- seedcode/commands/__init__.py +4 -2
- seedcode/commands/agent.py +7 -2
- seedcode/commands/assist.py +2 -1
- seedcode/commands/chat.py +46 -0
- seedcode/commands/codemode.py +103 -0
- seedcode/commands/mode.py +50 -0
- seedcode/commands/provider.py +52 -16
- seedcode/commands/status.py +95 -0
- seedcode/computer/controller.py +32 -3
- seedcode/computer/engine.py +3 -0
- seedcode/computer/keyboard.py +9 -1
- seedcode/computer/mouse.py +15 -6
- seedcode/computer/state.py +23 -1
- seedcode/config/__init__.py +20 -2
- seedcode/config/defaults.py +29 -2
- seedcode/config/manager.py +185 -3
- seedcode/core/agent.py +156 -12
- seedcode/core/chat.py +36 -4
- seedcode/core/http.py +171 -0
- seedcode/core/models.py +46 -14
- seedcode/core/providers/__init__.py +34 -7
- seedcode/core/providers/aerolink.py +4 -3
- seedcode/core/providers/base.py +24 -2
- seedcode/core/providers/default.py +157 -0
- seedcode/core/providers/freemodel.py +6 -5
- seedcode/core/providers/ollama.py +14 -5
- seedcode/core/providers/openrouter.py +83 -31
- seedcode/default_api.py +156 -0
- seedcode/defaults.py +37 -0
- seedcode/tools/base.py +16 -1
- seedcode/tools/desktop.py +24 -0
- seedcode/tools/filesystem.py +3 -1
- seedcode/tools/terminal.py +30 -5
- seedcode/ui/__init__.py +30 -12
- seedcode/ui/dashboard.py +121 -153
- seedcode/ui/menu.py +4 -0
- seedcode/ui/reference.py +46 -0
- seedcode/ui/renderer.py +27 -1
- seedcode/ui/selector.py +24 -0
- seedcode/utils/terminal_env.py +225 -0
- seedcode_cli-6.2.5.dist-info/METADATA +457 -0
- {seedcode_cli-6.1.5.dist-info → seedcode_cli-6.2.5.dist-info}/RECORD +54 -40
- {seedcode_cli-6.1.5.dist-info → seedcode_cli-6.2.5.dist-info}/WHEEL +1 -1
- seedcode/ui/banner.py +0 -78
- seedcode_cli-6.1.5.dist-info/METADATA +0 -368
- {seedcode_cli-6.1.5.dist-info → seedcode_cli-6.2.5.dist-info}/entry_points.txt +0 -0
- {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.
|
|
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
|
-
|
|
103
|
-
|
|
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=
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
658
|
+
_report_command_error(ui, exc)
|
seedcode/apps/discovery.py
CHANGED
|
@@ -19,7 +19,8 @@ from __future__ import annotations
|
|
|
19
19
|
|
|
20
20
|
import os
|
|
21
21
|
import shutil
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
seedcode/assets/logo.png
ADDED
|
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
|
-
|
|
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.")
|