Skip to content

Commit 3d2c759

Browse files
authored
Merge pull request #309 from tcdent/init-qol
Re-prompt user for project name instead of exiting, spinner for package install
2 parents 92818a8 + ff406ba commit 3d2c759

File tree

3 files changed

+156
-32
lines changed

3 files changed

+156
-32
lines changed

agentstack/cli/init.py

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,41 @@ def require_uv():
3636
raise EnvironmentError(message)
3737

3838

39+
def prompt_slug_name() -> str:
40+
"""Prompt the user for a project name."""
41+
42+
def _validate(slug_name: Optional[str]) -> bool:
43+
if not slug_name:
44+
log.error("Project name cannot be empty")
45+
return False
46+
47+
if not is_snake_case(slug_name):
48+
log.error("Project name must be snake_case")
49+
return False
50+
51+
if os.path.exists(conf.PATH / slug_name):
52+
log.error(f"Project path already exists: {conf.PATH / slug_name}")
53+
return False
54+
55+
return True
56+
57+
def _prompt() -> str:
58+
return inquirer.text(
59+
message="Project name (snake_case)",
60+
)
61+
62+
log.info(
63+
"Provide a project name. This will be used to create a new directory in the "
64+
"current path and will be used as the project name. 🐍 Must be snake_case."
65+
)
66+
slug_name = None
67+
while not _validate(slug_name):
68+
slug_name = _prompt()
69+
70+
assert slug_name # appease type checker
71+
return slug_name
72+
73+
3974
def select_template(slug_name: str, framework: Optional[str] = None) -> TemplateConfig:
4075
"""Let the user select a template from the ones available."""
4176
templates: list[TemplateConfig] = get_all_templates()
@@ -85,22 +120,11 @@ def init_project(
85120
welcome_message()
86121

87122
if not slug_name:
88-
log.info(
89-
"Provide a project name. This will be used to create a new directory in the "
90-
"current path and will be used as the project name. 🐍 Must be snake_case."
91-
)
92-
slug_name = inquirer.text(
93-
message="Project name (snake_case)",
94-
)
95-
96-
if not slug_name:
97-
raise Exception("Project name cannot be empty")
98-
if not is_snake_case(slug_name):
99-
raise Exception("Project name must be snake_case")
123+
slug_name = prompt_slug_name()
100124

101125
conf.set_path(conf.PATH / slug_name)
102-
if os.path.exists(conf.PATH): # cookiecutter requires the directory to not exist
103-
raise Exception(f"Directory already exists: {conf.PATH}")
126+
# cookiecutter requires the directory to not exist
127+
assert not os.path.exists(conf.PATH), f"Directory already exists: {conf.PATH}"
104128

105129
if use_wizard:
106130
log.debug("Initializing new project with wizard.")

agentstack/cli/spinner.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import itertools
2+
import shutil
3+
import sys
4+
import threading
5+
import time
6+
from typing import Optional, Literal
7+
8+
from agentstack import log
9+
10+
11+
class Spinner:
12+
def __init__(self, message="Working", delay=0.1):
13+
self.spinner = itertools.cycle(['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'])
14+
self.delay = delay
15+
self.message = message
16+
self.running = False
17+
self.spinner_thread = None
18+
self.start_time = None
19+
self._lock = threading.Lock()
20+
self._last_printed_len = 0
21+
self._last_message = ""
22+
23+
def _clear_line(self):
24+
"""Clear the current line in terminal."""
25+
sys.stdout.write('\r' + ' ' * self._last_printed_len + '\r')
26+
sys.stdout.flush()
27+
28+
def spin(self):
29+
while self.running:
30+
with self._lock:
31+
elapsed = time.time() - self.start_time
32+
terminal_width = shutil.get_terminal_size().columns
33+
spinner_char = next(self.spinner)
34+
time_str = f"{elapsed:.1f}s"
35+
36+
# Format: [spinner] Message... [time]
37+
message = f"\r{spinner_char} {self.message}... [{time_str}]"
38+
39+
# Ensure we don't exceed terminal width
40+
if len(message) > terminal_width:
41+
message = message[:terminal_width - 3] + "..."
42+
43+
# Clear previous line and print new one
44+
self._clear_line()
45+
sys.stdout.write(message)
46+
sys.stdout.flush()
47+
self._last_printed_len = len(message)
48+
49+
time.sleep(self.delay)
50+
51+
def __enter__(self):
52+
self.start()
53+
return self
54+
55+
def __exit__(self, exc_type, exc_val, exc_tb):
56+
self.stop()
57+
58+
def start(self):
59+
if not self.running:
60+
self.running = True
61+
self.start_time = time.time()
62+
self.spinner_thread = threading.Thread(target=self.spin)
63+
self.spinner_thread.start()
64+
65+
def stop(self):
66+
if self.running:
67+
self.running = False
68+
if self.spinner_thread:
69+
self.spinner_thread.join()
70+
with self._lock:
71+
self._clear_line()
72+
73+
def update_message(self, message):
74+
"""Update spinner message and ensure clean line."""
75+
with self._lock:
76+
self._clear_line()
77+
self.message = message
78+
79+
def clear_and_log(self, message, color: Literal['success', 'info'] = 'success'):
80+
"""Temporarily clear spinner, print message, and resume spinner.
81+
Skips printing if message is the same as the last message printed."""
82+
with self._lock:
83+
# Skip if message is same as last one
84+
if hasattr(self, '_last_message') and self._last_message == message:
85+
return
86+
87+
self._clear_line()
88+
if color == 'success':
89+
log.success(message)
90+
else:
91+
log.info(message)
92+
sys.stdout.flush()
93+
94+
# Store current message
95+
self._last_message = message

agentstack/packaging.py

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,44 +24,49 @@
2424
def install(package: str):
2525
"""Install a package with `uv` and add it to pyproject.toml."""
2626

27+
from agentstack.cli.spinner import Spinner
28+
2729
def on_progress(line: str):
2830
if RE_UV_PROGRESS.match(line):
29-
log.info(line.strip())
31+
spinner.clear_and_log(line.strip(), 'info')
3032

3133
def on_error(line: str):
3234
log.error(f"uv: [error]\n {line.strip()}")
33-
34-
log.info(f"Installing {package}")
35-
_wrap_command_with_callbacks(
36-
[get_uv_bin(), 'add', '--python', '.venv/bin/python', package],
37-
on_progress=on_progress,
38-
on_error=on_error,
39-
)
35+
36+
with Spinner(f"Installing {package}") as spinner:
37+
_wrap_command_with_callbacks(
38+
[get_uv_bin(), 'add', '--python', '.venv/bin/python', package],
39+
on_progress=on_progress,
40+
on_error=on_error,
41+
)
4042

4143

4244
def install_project():
4345
"""Install all dependencies for the user's project."""
4446

47+
from agentstack.cli.spinner import Spinner
48+
4549
def on_progress(line: str):
4650
if RE_UV_PROGRESS.match(line):
47-
log.info(line.strip())
51+
spinner.clear_and_log(line.strip(), 'info')
4852

4953
def on_error(line: str):
5054
log.error(f"uv: [error]\n {line.strip()}")
5155

5256
try:
53-
result = _wrap_command_with_callbacks(
54-
[get_uv_bin(), 'pip', 'install', '--python', '.venv/bin/python', '.'],
55-
on_progress=on_progress,
56-
on_error=on_error,
57-
)
58-
if result is False:
59-
log.info("Retrying uv installation with --no-cache flag...")
60-
_wrap_command_with_callbacks(
61-
[get_uv_bin(), 'pip', 'install', '--no-cache', '--python', '.venv/bin/python', '.'],
57+
with Spinner(f"Installing project dependencies.") as spinner:
58+
result = _wrap_command_with_callbacks(
59+
[get_uv_bin(), 'pip', 'install', '--python', '.venv/bin/python', '.'],
6260
on_progress=on_progress,
6361
on_error=on_error,
6462
)
63+
if result is False:
64+
spinner.clear_and_log("Retrying uv installation with --no-cache flag...", 'info')
65+
_wrap_command_with_callbacks(
66+
[get_uv_bin(), 'pip', 'install', '--no-cache', '--python', '.venv/bin/python', '.'],
67+
on_progress=on_progress,
68+
on_error=on_error,
69+
)
6570
except Exception as e:
6671
log.error(f"Installation failed: {str(e)}")
6772
raise

0 commit comments

Comments
 (0)