myg 0.1.0__tar.gz

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.
myg-0.1.0/.gitignore ADDED
@@ -0,0 +1,170 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # poetry
98
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102
+ #poetry.lock
103
+
104
+ # pdm
105
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106
+ #pdm.lock
107
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108
+ # in version control.
109
+ # https://pdm.fming.dev/#use-with-ide
110
+ .pdm.toml
111
+
112
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113
+ __pypackages__/
114
+
115
+ # Celery stuff
116
+ celerybeat-schedule
117
+ celerybeat.pid
118
+
119
+ # SageMath parsed files
120
+ *.sage.py
121
+
122
+ # Environments
123
+ .env
124
+ .venv
125
+ env/
126
+ venv/
127
+ ENV/
128
+ env.bak/
129
+ venv.bak/
130
+
131
+ # Spyder project settings
132
+ .spyderproject
133
+ .spyproject
134
+
135
+ # Rope project settings
136
+ .ropeproject
137
+
138
+ # mkdocs documentation
139
+ /site
140
+
141
+ # mypy
142
+ .mypy_cache/
143
+ .dmypy.json
144
+ dmypy.json
145
+
146
+ # Pyre type checker
147
+ .pyre/
148
+
149
+ # pytype static type analyzer
150
+ .pytype/
151
+
152
+ # Cython debug symbols
153
+ cython_debug/
154
+
155
+ # PyCharm
156
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
159
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160
+ #.idea/
161
+
162
+ # myg tooling
163
+ .ruff_cache/
164
+ basedpyright/
165
+ node_modules/
166
+ .next/
167
+ dist/
168
+ *.rej
169
+ *.orig
170
+ .devin/
myg-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.5
2
+ Name: myg
3
+ Version: 0.1.0
4
+ Summary: myg — your guy for agent apps. Scaffold a full CopilotKit + LangGraph agent app in one command.
5
+ Project-URL: Homepage, https://github.com/control-shift-escape-co/myguy
6
+ Project-URL: Documentation, https://control-shift-escape-co.github.io/myguy
7
+ Project-URL: Issues, https://github.com/control-shift-escape-co/myguy/issues
8
+ Author: Abhi Avadhani
9
+ License: MIT
10
+ Keywords: agents,cli,copilotkit,langgraph,scaffold
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: cookiecutter>=2.6
20
+ Requires-Dist: cruft>=2.15
21
+ Requires-Dist: httpx>=0.27
22
+ Requires-Dist: pydantic>=2.8
23
+ Requires-Dist: questionary>=2.0
24
+ Requires-Dist: rich>=13.7
25
+ Requires-Dist: tomli-w>=1.0
26
+ Requires-Dist: typer>=0.15
27
+ Provides-Extra: agent
28
+ Requires-Dist: claude-agent-sdk>=0.1.0; extra == 'agent'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # myg
32
+
33
+ Your guy for agent apps.
34
+
35
+ `myg init` scaffolds a production-shaped single-page agent app — Next.js + CopilotKit frontend, FastAPI + LangGraph backend (AG-UI), Postgres thread persistence, optional Kong AI Gateway — ready for `docker compose up` or a Railway deploy.
36
+
37
+ See the main repo for docs, contributing, and the full story: <https://github.com/control-shift-escape-co/myguy>
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ uvx myg init # recommended — no install
43
+ pipx run myg init # pipx
44
+ pip install myg # classic
45
+ npm create myg # npm folks — delegates to uvx/pipx
46
+ ```
47
+
48
+ ## Commands
49
+
50
+ | Command | What it does |
51
+ | --- | --- |
52
+ | `myg init` | Wizard → renders the template → optional instant Neon DB → next steps |
53
+ | `myg configure` | Edit `myg.toml` and re-render template files (`get`/`set`/`--ai`) |
54
+ | `myg prompt "…"` | Agentic edits to an existing app (Claude Agent SDK, or `--backend devin`) |
55
+ | `myg dev` | `docker compose up` — or runs both apps natively |
56
+ | `myg db` | Provisions an instant Neon Postgres + prints the claim URL |
57
+ | `myg deploy` | Deploys to Railway (CLI detected) or prints the guided steps |
58
+ | `myg doctor` | Checks docker/node/uv/keys/ports |
59
+ | `myg update` | Pulls template upgrades into your project (cruft) |
60
+ | `myg templates` | Lists built-in + community templates |
61
+
62
+ MIT licensed.
myg-0.1.0/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # myg
2
+
3
+ Your guy for agent apps.
4
+
5
+ `myg init` scaffolds a production-shaped single-page agent app — Next.js + CopilotKit frontend, FastAPI + LangGraph backend (AG-UI), Postgres thread persistence, optional Kong AI Gateway — ready for `docker compose up` or a Railway deploy.
6
+
7
+ See the main repo for docs, contributing, and the full story: <https://github.com/control-shift-escape-co/myguy>
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ uvx myg init # recommended — no install
13
+ pipx run myg init # pipx
14
+ pip install myg # classic
15
+ npm create myg # npm folks — delegates to uvx/pipx
16
+ ```
17
+
18
+ ## Commands
19
+
20
+ | Command | What it does |
21
+ | --- | --- |
22
+ | `myg init` | Wizard → renders the template → optional instant Neon DB → next steps |
23
+ | `myg configure` | Edit `myg.toml` and re-render template files (`get`/`set`/`--ai`) |
24
+ | `myg prompt "…"` | Agentic edits to an existing app (Claude Agent SDK, or `--backend devin`) |
25
+ | `myg dev` | `docker compose up` — or runs both apps natively |
26
+ | `myg db` | Provisions an instant Neon Postgres + prints the claim URL |
27
+ | `myg deploy` | Deploys to Railway (CLI detected) or prints the guided steps |
28
+ | `myg doctor` | Checks docker/node/uv/keys/ports |
29
+ | `myg update` | Pulls template upgrades into your project (cruft) |
30
+ | `myg templates` | Lists built-in + community templates |
31
+
32
+ MIT licensed.
@@ -0,0 +1,3 @@
1
+ """myg — your guy for agent apps."""
2
+
3
+ __version__ = "0.1.0"
myg-0.1.0/myg/cli.py ADDED
@@ -0,0 +1,59 @@
1
+ """myg CLI — your guy for agent apps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ # Windows consoles default to cp1252 — unicode output must not depend on locale.
8
+ for stream in (sys.stdout, sys.stderr):
9
+ if getattr(stream, "encoding", "") and stream.encoding.lower() not in ("utf-8", "utf8"):
10
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
11
+
12
+ import typer # noqa: E402
13
+
14
+ from myg import __version__ # noqa: E402
15
+ from myg.commands import ( # noqa: E402
16
+ configure,
17
+ db,
18
+ deploy,
19
+ dev,
20
+ doctor,
21
+ eject,
22
+ init,
23
+ prompt,
24
+ templates,
25
+ update,
26
+ )
27
+
28
+ app = typer.Typer(
29
+ name="myg",
30
+ help="Your guy for agent apps — scaffold a CopilotKit + LangGraph agent in one command.",
31
+ no_args_is_help=True,
32
+ rich_markup_mode="rich",
33
+ add_completion=False,
34
+ )
35
+
36
+ app.command()(init.init)
37
+ app.command()(configure.configure)
38
+ app.command()(db.db)
39
+ app.command("db-claim")(db.claim_url)
40
+ app.command()(prompt.prompt)
41
+ app.command()(dev.dev)
42
+ app.command()(deploy.deploy)
43
+ app.command()(doctor.doctor)
44
+ app.command()(update.update)
45
+ app.command()(eject.eject)
46
+ app.command()(templates.templates)
47
+
48
+
49
+ @app.callback(invoke_without_command=True)
50
+ def main(
51
+ version: bool = typer.Option(False, "--version", "-V", help="Print version"),
52
+ ) -> None:
53
+ if version:
54
+ typer.echo(f"myg {__version__}")
55
+ raise typer.Exit()
56
+
57
+
58
+ if __name__ == "__main__":
59
+ app()
File without changes
@@ -0,0 +1,136 @@
1
+ """`myg configure` — edit myg.toml values and re-render template files in place."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import tomllib
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import questionary
11
+ import tomli_w
12
+ import typer
13
+ from rich.console import Console
14
+
15
+ from myg import render, wizard
16
+ from myg.context import MygContext, find_project_root
17
+
18
+ console = Console()
19
+
20
+ _FLAT_KEYS = {
21
+ "name": "project_name",
22
+ "pattern": "agent_pattern",
23
+ "model": "model",
24
+ "prompt": "system_prompt",
25
+ "system_prompt": "system_prompt",
26
+ "style": "ui_style",
27
+ "db": "db",
28
+ "gateway": "gateway",
29
+ }
30
+ _SECTION_KEYS = {
31
+ "agent.pattern": "agent_pattern",
32
+ "agent.model": "model",
33
+ "agent.system_prompt": "system_prompt",
34
+ "ui.style": "ui_style",
35
+ "ui.primary_color": "primary_color",
36
+ "ui.secondary_color": "secondary_color",
37
+ "db.provider": "db",
38
+ "gateway.mode": "gateway",
39
+ "project.name": "project_name",
40
+ "project.description": "description",
41
+ }
42
+
43
+
44
+ def configure(
45
+ set_: Annotated[
46
+ list[str] | None,
47
+ typer.Option("--set", "-s", help="key=value, e.g. agent.pattern=supervisor"),
48
+ ] = None,
49
+ get: Annotated[str | None, typer.Option("--get", "-g", help="Print a config value")] = None,
50
+ ai: Annotated[bool, typer.Option("--ai", help="Reconfigure by chatting with an LLM")] = False,
51
+ github_style: Annotated[
52
+ str | None, typer.Option("--github-style", help="Extract UI style from a GitHub repo")
53
+ ] = None,
54
+ project_dir: Annotated[Path | None, typer.Option("--dir", "-d")] = None,
55
+ no_render: Annotated[bool, typer.Option("--no-render", help="Only update myg.toml")] = False,
56
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts")] = False,
57
+ ) -> None:
58
+ """Change config on an existing generated app."""
59
+ root = project_dir or find_project_root()
60
+ toml_path = root / "myg.toml"
61
+ ctx = MygContext.from_myg_toml(toml_path)
62
+
63
+ if get:
64
+ key = _SECTION_KEYS.get(get, _FLAT_KEYS.get(get, get))
65
+ console.print(getattr(ctx, key))
66
+ raise typer.Exit()
67
+
68
+ changed = False
69
+ if github_style:
70
+ with console.status(f"Extracting style from {github_style}…"):
71
+ for key, value in wizard.style_from_github(github_style).items():
72
+ setattr(ctx, key, value)
73
+ changed = True
74
+ for assignment in set_ or []:
75
+ key, _, value = assignment.partition("=")
76
+ attr = _SECTION_KEYS.get(key.strip(), _FLAT_KEYS.get(key.strip(), key.strip()))
77
+ if not hasattr(ctx, attr):
78
+ console.print(f"[red]Unknown config key: {key}[/red]")
79
+ raise typer.Exit(2)
80
+ setattr(ctx, attr, value.strip())
81
+ changed = True
82
+ if ai:
83
+ ctx = wizard.run_assist(ctx)
84
+ changed = True
85
+ if not changed:
86
+ console.print("Nothing to do — pass --set, --ai or --github-style.")
87
+ raise typer.Exit()
88
+
89
+ if not no_render and _git_dirty(root) and not (yes or _confirm_overwrite()):
90
+ console.print("Aborted — nothing written.")
91
+ raise typer.Abort()
92
+
93
+ ctx.write_myg_toml(toml_path)
94
+ console.print("[green]✓[/green] myg.toml updated")
95
+ if no_render:
96
+ return
97
+ with console.status("Re-rendering template files…"):
98
+ render.rerender(root, ctx.cookiecutter_context())
99
+ console.print("[green]✓[/green] Template files re-rendered")
100
+
101
+
102
+ def _confirm_overwrite() -> bool:
103
+ try:
104
+ return bool(
105
+ questionary.confirm(
106
+ "Uncommitted changes detected — re-render may overwrite template files. Continue?",
107
+ default=False,
108
+ ).ask()
109
+ )
110
+ except Exception:
111
+ console.print(
112
+ "[yellow]Non-interactive shell — pass --yes to re-render over a dirty tree.[/yellow]"
113
+ )
114
+ return False
115
+
116
+
117
+ def _git_dirty(root: Path) -> bool:
118
+ try:
119
+ out = subprocess.run(
120
+ ["git", "status", "--porcelain"], cwd=root, capture_output=True, text=True, timeout=10
121
+ )
122
+ return bool(out.stdout.strip())
123
+ except Exception:
124
+ return False
125
+
126
+
127
+ def read_db_section(root: Path) -> dict:
128
+ data = tomllib.loads((root / "myg.toml").read_text(encoding="utf-8"))
129
+ return data.get("db", {})
130
+
131
+
132
+ def update_db_section(root: Path, values: dict) -> None:
133
+ path = root / "myg.toml"
134
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
135
+ data.setdefault("db", {}).update(values)
136
+ path.write_text(tomli_w.dumps(data), encoding="utf-8")
@@ -0,0 +1,64 @@
1
+ """`myg db` — instant Neon Postgres for a generated project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+
12
+ from myg import neon
13
+ from myg.commands.configure import read_db_section, update_db_section
14
+ from myg.context import find_project_root
15
+
16
+ console = Console()
17
+
18
+
19
+ def db(
20
+ db_id: Annotated[
21
+ str | None, typer.Option("--status", help="Check status of a provisioned DB id")
22
+ ] = None,
23
+ project_dir: Annotated[Path | None, typer.Option("--dir", "-d")] = None,
24
+ ) -> None:
25
+ """Provision an instant Neon Postgres (no account needed) and wire it into .env."""
26
+ if db_id:
27
+ console.print_json(data=neon.status(db_id))
28
+ raise typer.Exit()
29
+
30
+ root = project_dir or find_project_root()
31
+ with console.status("Provisioning instant Neon Postgres…"):
32
+ ndb = neon.provision(ref=root.name)
33
+ env_file = neon.write_database_url(root, ndb.connection_string)
34
+ update_db_section(
35
+ root,
36
+ {
37
+ "provider": "neon",
38
+ "project_id": ndb.id,
39
+ "claim_url": ndb.claim_url,
40
+ "expires_at": ndb.expires_at,
41
+ },
42
+ )
43
+ console.print(f"[green]✓[/green] DATABASE_URL written to {env_file}")
44
+ console.print(
45
+ Panel(
46
+ f"[bold]This DB expires in 72h unless claimed.[/bold]\n\n"
47
+ "Claim it (free Neon account, no card):\n"
48
+ f"[link={ndb.claim_url}]{ndb.claim_url}[/link]\n\n"
49
+ f"DB id: {ndb.id} (check later: myg db --status {ndb.id})",
50
+ title="⚡ instant postgres",
51
+ border_style="yellow",
52
+ )
53
+ )
54
+
55
+
56
+ def claim_url(project_dir: Annotated[Path | None, typer.Option("--dir", "-d")] = None) -> None:
57
+ """Reprint the claim URL stored in myg.toml."""
58
+ root = project_dir or find_project_root()
59
+ info = read_db_section(root)
60
+ url = info.get("claim_url")
61
+ if not url:
62
+ console.print("[yellow]No claim URL recorded — run `myg db` first.[/yellow]")
63
+ raise typer.Exit(1)
64
+ console.print(url)
@@ -0,0 +1,38 @@
1
+ """`myg deploy` — ship the generated app to Railway."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+
14
+ from myg.context import find_project_root
15
+
16
+ console = Console()
17
+
18
+
19
+ def deploy(project_dir: Annotated[Path | None, typer.Option("--dir", "-d")] = None) -> None:
20
+ """Deploy to Railway — uses the railway CLI if installed, else prints the guide."""
21
+ root = (project_dir or find_project_root()).resolve()
22
+ if shutil.which("railway"):
23
+ console.print("Handing off to [bold]railway up[/bold] — follow its prompts.")
24
+ raise typer.Exit(subprocess.run(["railway", "up"], cwd=root).returncode)
25
+ console.print(
26
+ Panel(
27
+ "Railway CLI not found — no problem, the dashboard flow is 2 minutes:\n\n"
28
+ "1. Push this project to GitHub\n"
29
+ "2. railway.app → New Project → Deploy from GitHub repo\n"
30
+ "3. Add TWO services with root dirs: "
31
+ "[bold]apps/web[/bold] and [bold]apps/agent[/bold]\n"
32
+ "4. Add a Postgres plugin (or reuse Neon) → set DATABASE_URL on agent\n"
33
+ "5. Set env vars on each service (see .env.example + RAILWAY.md)\n\n"
34
+ "Or: npm i -g @railway/cli && railway login && myg deploy",
35
+ title="deploy to railway",
36
+ border_style="blue",
37
+ )
38
+ )
@@ -0,0 +1,79 @@
1
+ """`myg dev` — run the generated app: docker compose when available, else natively."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import signal
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from myg.context import find_project_root
15
+
16
+ console = Console()
17
+
18
+
19
+ def dev(
20
+ detach: Annotated[bool, typer.Option("-d", "--detach", help="Compose in background")] = False,
21
+ project_dir: Annotated[Path | None, typer.Option("--dir", "-d")] = None,
22
+ ) -> None:
23
+ """Start the generated app locally."""
24
+ root = (project_dir or find_project_root()).resolve()
25
+ if shutil.which("docker") and _compose_up(root, detach):
26
+ return
27
+ console.print(
28
+ "[yellow]Docker not found — starting apps natively (needs uv + node/pnpm).[/yellow]"
29
+ )
30
+ _native(root)
31
+
32
+
33
+ def _compose_up(root: Path, detach: bool) -> bool:
34
+ cmd = ["docker", "compose", "up"] + (["-d"] if detach else [])
35
+ try:
36
+ subprocess.run(cmd, cwd=root, check=True)
37
+ if detach:
38
+ console.print("[green]✓[/green] Running — http://localhost:3000")
39
+ return True
40
+ except subprocess.CalledProcessError as exc:
41
+ console.print(f"[red]docker compose failed ({exc.returncode})[/red]")
42
+ raise typer.Exit(exc.returncode or 1) from None
43
+
44
+
45
+ def _native(root: Path) -> None:
46
+ uv, pnpm, npm = shutil.which("uv"), shutil.which("pnpm"), shutil.which("npm")
47
+ if not uv or not (pnpm or npm):
48
+ console.print(
49
+ "[red]Need `uv` and `pnpm`/`npm` for native dev, or Docker for compose.[/red]"
50
+ )
51
+ raise typer.Exit(1)
52
+ agent = subprocess.Popen(
53
+ [
54
+ uv,
55
+ "run",
56
+ "--directory",
57
+ str(root / "apps/agent"),
58
+ "uvicorn",
59
+ "agent.main:app",
60
+ "--host",
61
+ "127.0.0.1",
62
+ "--port",
63
+ "8123",
64
+ "--reload",
65
+ ],
66
+ cwd=root,
67
+ )
68
+ pm = pnpm or npm
69
+ web = subprocess.Popen([pm, "--dir", str(root / "apps/web"), "dev"], cwd=root, shell=True)
70
+ console.print("agent → http://127.0.0.1:8123 · web → http://localhost:3000 (Ctrl+C to stop)")
71
+ try:
72
+ web.wait()
73
+ except KeyboardInterrupt:
74
+ pass
75
+ finally:
76
+ for proc in (web, agent):
77
+ proc.send_signal(signal.SIGINT)
78
+ web.wait(timeout=10)
79
+ agent.wait(timeout=10)