diff --git a/agentstack/cli/__init__.py b/agentstack/cli/__init__.py index 32c08ec3..d4d83bbc 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 1644f109..41770572 100644 --- a/agentstack/cli/cli.py +++ b/agentstack/cli/cli.py @@ -86,7 +86,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() @@ -94,7 +93,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 = { @@ -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']) @@ -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) @@ -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): """ diff --git a/agentstack/cli/init.py b/agentstack/cli/init.py new file mode 100644 index 00000000..6fd546de --- /dev/null +++ b/agentstack/cli/init.py @@ -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 `") + 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" + ) diff --git a/agentstack/exceptions.py b/agentstack/exceptions.py index c0e95569..65a433eb 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 ff512982..b725e079 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -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, @@ -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() diff --git a/agentstack/packaging.py b/agentstack/packaging.py index fb0e3cb5..b472a51e 100644 --- a/agentstack/packaging.py +++ b/agentstack/packaging.py @@ -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 diff --git a/agentstack/telemetry.py b/agentstack/telemetry.py index 0008efbc..db6123ee 100644 --- a/agentstack/telemetry.py +++ b/agentstack/telemetry.py @@ -76,7 +76,7 @@ def collect_machine_telemetry(command: str): def track_cli_command(command: str, args: Optional[str] = None): - if bool(os.environ['AGENTSTATCK_IS_TEST_ENV']): + if bool(os.getenv('AGENTSTACK_IS_TEST_ENV')): return try: @@ -91,7 +91,7 @@ def track_cli_command(command: str, args: Optional[str] = None): pass def update_telemetry(id: int, result: int, message: Optional[str] = None): - if bool(os.environ['AGENTSTATCK_IS_TEST_ENV']): + if bool(os.getenv('AGENTSTACK_IS_TEST_ENV')): return try: 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 77205fe8..f6dc4cf6 100644 --- a/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/pyproject.toml +++ b/agentstack/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/pyproject.toml @@ -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}}", +] \ No newline at end of file diff --git a/docs/installation.mdx b/docs/installation.mdx index 5238cafc..042db2eb 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -4,28 +4,50 @@ description: 'Installing AgentStack is super easy!' icon: 'cloud-arrow-down' --- -Use your favorite package manager - - +## Installing with Brew +```bash +brew tap agentops-ai/tap +brew install agentstack +``` -```bash PIP -pip install agentstack +## Installing with pipx +```bash +pipx install agentstack ``` -```bash brew -brew tap agentstack-ai/tap -brew install agentstack + +## Installing with UV + + + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh ``` -```bash uv -uv install agentstack + + +```bash +uv venv ``` - + + + ```bash + uv pip install agentstack + ``` + + - - Conda is not supported at this time. AgentStack uses Poetry in your project which [does not play well with Conda](https://github.com/AgentOps-AI/AgentStack/issues/40). - ## After installing Run `agentstack --version` to verify that the CLI is installed and accessible. -**Congrats! You now have AgentStack installed in your environment** \ No newline at end of file +**Congrats! You now have AgentStack installed in your environment** + + + + A quickstart guide to using the CLI + + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 915af8f1..e1ecc0a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "requests>=2.32", "appdirs>=1.4.4", "python-dotenv>=1.0.1", + "uv>=0.5.6", ] [project.optional-dependencies] diff --git a/tox.ini b/tox.ini index b37cd4ac..dd39747c 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,7 @@ commands = coverage xml mypy: mypy agentstack setenv = - AGENTSTATCK_IS_TEST_ENV = 1 + AGENTSTACK_IS_TEST_ENV = 1 AGENTSTACK_TELEMETRY_OPT_OUT = 1 AGENTSTACK_UPDATE_DISABLE = 1