Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion agentstack/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .cli import init_project_builder, configure_default_model, export_template
from .cli import init_project_builder, configure_default_model, export_template, welcome_message
from .init import init_project
from .tools import list_tools, add_tool
from .run import run_project
41 changes: 8 additions & 33 deletions agentstack/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,13 @@ def init_project_builder(
tools = [tools.model_dump() for tools in template_data.tools]

elif use_wizard:
welcome_message()
project_details = ask_project_details(slug_name)
welcome_message()
framework = ask_framework()
design = ask_design()
tools = ask_tools()

else:
welcome_message()
# the user has started a new project; let's give them something to work with
default_project = TemplateConfig.from_template_name('hello_alex')
project_details = {
Expand All @@ -115,9 +113,6 @@ def init_project_builder(
log.debug(f"project_details: {project_details}" f"framework: {framework}" f"design: {design}")
insert_template(project_details, framework, design, template_data)

# we have an agentstack.json file in the directory now
conf.set_path(project_details['name'])

for tool_data in tools:
generation.add_tool(tool_data['name'], agents=tool_data['agents'])

Expand Down Expand Up @@ -410,14 +405,14 @@ def insert_template(
f'{template_path}/{"{{cookiecutter.project_metadata.project_slug}}"}/.env',
)

if os.path.isdir(project_details['name']):
print(
term_color(
f"Directory {template_path} already exists. Please check this and try again",
"red",
)
)
sys.exit(1)
# if os.path.isdir(project_details['name']):
# print(
# term_color(
# f"Directory {template_path} already exists. Please check this and try again",
# "red",
# )
# )
# sys.exit(1)

cookiecutter(str(template_path), no_input=True, extra_context=None)

Expand All @@ -431,26 +426,6 @@ def insert_template(
except:
print("Failed to initialize git repository. Maybe you're already in one? Do this with: git init")

# TODO: check if poetry is installed and if so, run poetry install in the new directory
# os.system("poetry install")
# os.system("cls" if os.name == "nt" else "clear")
# TODO: add `agentstack docs` command
print(
"\n"
"🚀 \033[92mAgentStack project generated successfully!\033[0m\n\n"
" Next, run:\n"
f" cd {project_metadata.project_slug}\n"
" python -m venv .venv\n"
" source .venv/bin/activate\n\n"
" Make sure you have the latest version of poetry installed:\n"
" pip install -U poetry\n\n"
" You'll need to install the project's dependencies with:\n"
" poetry install\n\n"
" Finally, try running your agent with:\n"
" agentstack run\n\n"
" Run `agentstack quickstart` or `agentstack docs` for next steps.\n"
)


def export_template(output_filename: str):
"""
Expand Down
72 changes: 72 additions & 0 deletions agentstack/cli/init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import os, sys
from typing import Optional
from pathlib import Path
from agentstack import conf
from agentstack import packaging
from agentstack.cli import welcome_message, init_project_builder
from agentstack.utils import term_color


# TODO move the rest of the CLI init tooling into this file


def require_uv():
try:
uv_bin = packaging.get_uv_bin()
assert os.path.exists(uv_bin)
except (AssertionError, ImportError):
print(term_color("Error: uv is not installed.", 'red'))
print("Full installation instructions at: https://docs.astral.sh/uv/getting-started/installation")
match sys.platform:
case 'linux' | 'darwin':
print("Hint: run `curl -LsSf https://astral.sh/uv/install.sh | sh`")
case _:
pass
sys.exit(1)


def init_project(
slug_name: Optional[str] = None,
template: Optional[str] = None,
use_wizard: bool = False,
):
"""
Initialize a new project in the current directory.

- create a new virtual environment
- copy project skeleton
- install dependencies
"""
require_uv()

# TODO prevent the user from passing the --path arguent to init
if slug_name:
conf.set_path(conf.PATH / slug_name)
else:
print("Error: No project directory specified.")
print("Run `agentstack init <project_name>`")
sys.exit(1)

if os.path.exists(conf.PATH): # cookiecutter requires the directory to not exist
print(f"Error: Directory already exists: {conf.PATH}")
sys.exit(1)

welcome_message()
print(term_color("🦾 Creating a new AgentStack project...", 'blue'))
print(f"Using project directory: {conf.PATH.absolute()}")

# copy the project skeleton, create a virtual environment, and install dependencies
init_project_builder(slug_name, template, use_wizard)
packaging.create_venv()
packaging.install_project()

print(
"\n"
"🚀 \033[92mAgentStack project generated successfully!\033[0m\n\n"
" To get started, activate the virtual environment with:\n"
f" cd {conf.PATH}\n"
" source .venv/bin/activate\n\n"
" Run your new agent with:\n"
" agentstack run\n\n"
" Or, run `agentstack quickstart` or `agentstack docs` for more next steps.\n"
)
9 changes: 9 additions & 0 deletions agentstack/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,12 @@ class ValidationError(Exception):
"""

pass


class EnvironmentError(Exception):
"""
Raised when an error occurs in the execution environment ie. a command is
not present or the environment is not configured as expected.
"""

pass
4 changes: 2 additions & 2 deletions agentstack/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from agentstack import conf, auth
from agentstack.cli import (
init_project_builder,
init_project,
add_tool,
list_tools,
configure_default_model,
Expand Down Expand Up @@ -167,7 +167,7 @@ def main():
elif args.command in ["templates"]:
webbrowser.open("https://docs.agentstack.sh/quickstart")
elif args.command in ["init", "i"]:
init_project_builder(args.slug_name, args.template, args.wizard)
init_project(args.slug_name, args.template, args.wizard)
elif args.command in ["tools", "t"]:
if args.tools_command in ["list", "l"]:
list_tools()
Expand Down
173 changes: 164 additions & 9 deletions agentstack/packaging.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,173 @@
import os
from typing import Optional
import os, sys
from typing import Optional, Callable
from pathlib import Path
import re
import subprocess
import select
from agentstack import conf

PACKAGING_CMD = "poetry"

DEFAULT_PYTHON_VERSION = "3.12"
VENV_DIR_NAME: Path = Path(".venv")

def install(package: str, path: Optional[str] = None):
if path:
os.chdir(path)
os.system(f"{PACKAGING_CMD} add {package}")
# filter uv output by these words to only show useful progress messages
RE_UV_PROGRESS = re.compile(r'^(Resolved|Prepared|Installed|Uninstalled|Audited)')


# When calling `uv` we explicitly specify the --python executable to use so that
# the packages are installed into the correct virtual environment.
# In testing, when this was not set, packages could end up in the pyenv's
# site-packages directory; it's possible an environemnt variable can control this.


def install(package: str):
"""Install a package with `uv` and add it to pyproject.toml."""

def on_progress(line: str):
if RE_UV_PROGRESS.match(line):
print(line.strip())

def on_error(line: str):
print(f"uv: [error]\n {line.strip()}")

_wrap_command_with_callbacks(
[get_uv_bin(), 'add', '--python', '.venv/bin/python', package],
on_progress=on_progress,
on_error=on_error,
)


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

def on_progress(line: str):
if RE_UV_PROGRESS.match(line):
print(line.strip())

def on_error(line: str):
print(f"uv: [error]\n {line.strip()}")

_wrap_command_with_callbacks(
[get_uv_bin(), 'pip', 'install', '--python', '.venv/bin/python', '.'],
on_progress=on_progress,
on_error=on_error,
)


def remove(package: str):
os.system(f"{PACKAGING_CMD} remove {package}")
"""Uninstall a package with `uv`."""

# TODO it may be worth considering removing unused sub-dependencies as well
def on_progress(line: str):
if RE_UV_PROGRESS.match(line):
print(line.strip())

def on_error(line: str):
print(f"uv: [error]\n {line.strip()}")

_wrap_command_with_callbacks(
[get_uv_bin(), 'remove', '--python', '.venv/bin/python', package],
on_progress=on_progress,
on_error=on_error,
)


def upgrade(package: str):
os.system(f"{PACKAGING_CMD} add {package}")
"""Upgrade a package with `uv`."""

# TODO should we try to update the project's pyproject.toml as well?
def on_progress(line: str):
if RE_UV_PROGRESS.match(line):
print(line.strip())

def on_error(line: str):
print(f"uv: [error]\n {line.strip()}")

_wrap_command_with_callbacks(
[get_uv_bin(), 'pip', 'install', '-U', '--python', '.venv/bin/python', package],
on_progress=on_progress,
on_error=on_error,
)


def create_venv(python_version: str = DEFAULT_PYTHON_VERSION):
"""Intialize a virtual environment in the project directory of one does not exist."""
if os.path.exists(conf.PATH / VENV_DIR_NAME):
return # venv already exists

RE_VENV_PROGRESS = re.compile(r'^(Using|Creating)')

def on_progress(line: str):
if RE_VENV_PROGRESS.match(line):
print(line.strip())

def on_error(line: str):
print(f"uv: [error]\n {line.strip()}")

_wrap_command_with_callbacks(
[get_uv_bin(), 'venv', '--python', python_version],
on_progress=on_progress,
on_error=on_error,
)


def get_uv_bin() -> str:
"""Find the path to the uv binary."""
try:
import uv

return uv.find_uv_bin()
except ImportError as e:
raise e


def _setup_env() -> dict[str, str]:
"""Copy the current environment and add the virtual environment path for use by a subprocess."""
env = os.environ.copy()
env["VIRTUAL_ENV"] = str(conf.PATH / VENV_DIR_NAME.absolute())
env["UV_INTERNAL__PARENT_INTERPRETER"] = sys.executable
return env


def _wrap_command_with_callbacks(
command: list[str],
on_progress: Callable[[str], None] = lambda x: None,
on_complete: Callable[[str], None] = lambda x: None,
on_error: Callable[[str], None] = lambda x: None,
) -> None:
"""Run a command with progress callbacks."""
try:
all_lines = ''
process = subprocess.Popen(
command,
cwd=conf.PATH.absolute(),
env=_setup_env(),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
assert process.stdout and process.stderr # appease type checker

readable = [process.stdout, process.stderr]
while readable:
ready, _, _ = select.select(readable, [], [])
for fd in ready:
line = fd.readline()
if not line:
readable.remove(fd)
continue

on_progress(line)
all_lines += line

if process.wait() == 0: # return code: success
on_complete(all_lines)
else:
on_error(all_lines)
except Exception as e:
on_error(str(e))
finally:
try:
process.terminate()
except:
pass
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
[tool.poetry]
[project]
name = "{{cookiecutter.project_metadata.project_name}}"
version = "{{cookiecutter.project_metadata.version}}"
description = "{{cookiecutter.project_metadata.description}}"
authors = ["{{cookiecutter.project_metadata.author_name}}"]
license = "{{cookiecutter.project_metadata.license}}"
package-mode = false
authors = [
{ name = "{{cookiecutter.project_metadata.author_name}}" }
]
license = { text = "{{cookiecutter.project_metadata.license}}" }
requires-python = ">=3.10"

[tool.poetry.dependencies]
python = ">=3.10,<=3.13"
agentstack = {extras = ["{{cookiecutter.framework}}"], version="{{cookiecutter.project_metadata.agentstack_version}}"}
dependencies = [
"agentstack[{{cookiecutter.framework}}]>={{cookiecutter.project_metadata.agentstack_version}}",
]
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies = [
"requests>=2.32",
"appdirs>=1.4.4",
"python-dotenv>=1.0.1",
"uv>=0.5.6",
]

[project.optional-dependencies]
Expand Down