diff --git a/agentstack/cli/__init__.py b/agentstack/cli/__init__.py index 32c08ec..d4d83bb 100644 --- a/agentstack/cli/__init__.py +++ b/agentstack/cli/__init__.py @@ -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 \ No newline at end of file diff --git a/agentstack/cli/cli.py b/agentstack/cli/cli.py index 0c085d5..8837cd9 100644 --- a/agentstack/cli/cli.py +++ b/agentstack/cli/cli.py @@ -88,7 +88,6 @@ 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() @@ -96,7 +95,6 @@ def init_project_builder( 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 = { @@ -117,9 +115,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']) @@ -395,14 +390,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) @@ -420,21 +415,21 @@ def insert_template( # 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" - ) + # 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): diff --git a/agentstack/cli/init.py b/agentstack/cli/init.py new file mode 100644 index 0000000..6ce201a --- /dev/null +++ b/agentstack/cli/init.py @@ -0,0 +1,70 @@ +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 + """ + welcome_message() + + # conf.PATH may have been set by the argument parser, but if not, use the slug_name + if slug_name: + conf.set_path(conf.PATH / slug_name) + else: + print("Error: No project directory specified.") + print("Run `agentstack init or use the --path flag.") + sys.exit(1) + + if os.path.exists(conf.PATH): + print(f"Error: Directory already exists: {conf.PATH}") + sys.exit(1) + + 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('.') + + 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" + ) diff --git a/agentstack/cli/run.py b/agentstack/cli/run.py index 17c48b4..c05ccd3 100644 --- a/agentstack/cli/run.py +++ b/agentstack/cli/run.py @@ -83,6 +83,7 @@ def _import_project_module(path: Path): assert spec is not None # appease type checker assert spec.loader is not None # appease type checker + print('dev version') project_module = importlib.util.module_from_spec(spec) sys.path.append(str((path / MAIN_FILENAME).parent)) spec.loader.exec_module(project_module) diff --git a/agentstack/exceptions.py b/agentstack/exceptions.py index c0e9556..65a433e 100644 --- a/agentstack/exceptions.py +++ b/agentstack/exceptions.py @@ -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 diff --git a/agentstack/main.py b/agentstack/main.py index eac6482..24d6977 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -4,7 +4,7 @@ from agentstack import conf from agentstack.cli import ( - init_project_builder, + init_project, add_tool, list_tools, configure_default_model, @@ -162,7 +162,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 ["run", "r"]: run_project(command=args.function, debug=args.debug, cli_args=extra_args) elif args.command in ['generate', 'g']: diff --git a/agentstack/packaging.py b/agentstack/packaging.py index fb0e3cb..729a28c 100644 --- a/agentstack/packaging.py +++ b/agentstack/packaging.py @@ -1,18 +1,131 @@ -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 +from agentstack.exceptions import EnvironmentError -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}") + +def install(package: str): + """ + Install a package with `uv`. + Filter output to only show useful progress messages. + """ + RE_USEFUL_PROGRESS = re.compile(r'^(Resolved|Prepared|Installed|Audited)') + + def on_progress(line: str): + # only print these four messages: + # Resolved 78 packages in 225ms + # Prepared 12 packages in 915ms + # Installed 78 packages in 65ms + # Audited 1 package in 28ms + if RE_USEFUL_PROGRESS.match(line): + print(line.strip()) + + def on_error(line: str): + print(f"uv: [error]\n {line.strip()}") + + # explicitly specify the --python executable to use so that the packages + # are installed into the correct virtual environment + _wrap_command_with_callbacks( + [get_uv_bin(), 'pip', 'install', '--python', '.venv/bin/python', package], + on_progress=on_progress, + on_error=on_error, + ) def remove(package: str): - os.system(f"{PACKAGING_CMD} remove {package}") + raise NotImplementedError("TODO `packaging.remove`") def upgrade(package: str): - os.system(f"{PACKAGING_CMD} add {package}") + raise NotImplementedError("TODO `packaging.upgrade`") + + +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_USEFUL_PROGRESS = re.compile(r'^(Using|Creating)') + + def on_progress(line: str): + if RE_USEFUL_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.setdefault("VIRTUAL_ENV", VENV_DIR_NAME) + 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 diff --git a/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/pyproject.toml b/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/pyproject.toml index a8d8807..f931a97 100644 --- a/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/pyproject.toml +++ b/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/pyproject.toml @@ -1,18 +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}}"} - -[project.scripts] -{{cookiecutter.project_metadata.project_name}} = "{{cookiecutter.project_metadata.project_name}}.main:run" -run_crew = "{{cookiecutter.project_metadata.project_name}}.main:run" -train = "{{cookiecutter.project_metadata.project_name}}.main:train" -replay = "{{cookiecutter.project_metadata.project_name}}.main:replay" -test = "{{cookiecutter.project_metadata.project_name}}.main:test" \ No newline at end of file +dependencies = [ + "agentstack[{{cookiecutter.framework}}]=={{cookiecutter.project_metadata.agentstack_version}}", +] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 69f3c30..a06404e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "requests>=2.32", "appdirs>=1.4.4", "python-dotenv>=1.0.1", + "uv>=0.5.6", ] [project.optional-dependencies]