indent 0.0.8__py3-none-any.whl

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.

Potentially problematic release.


This version of indent might be problematic. Click here for more details.

Files changed (56) hide show
  1. exponent/__init__.py +1 -0
  2. exponent/cli.py +112 -0
  3. exponent/commands/cloud_commands.py +85 -0
  4. exponent/commands/common.py +434 -0
  5. exponent/commands/config_commands.py +581 -0
  6. exponent/commands/github_app_commands.py +211 -0
  7. exponent/commands/listen_commands.py +96 -0
  8. exponent/commands/run_commands.py +208 -0
  9. exponent/commands/settings.py +56 -0
  10. exponent/commands/shell_commands.py +2840 -0
  11. exponent/commands/theme.py +246 -0
  12. exponent/commands/types.py +111 -0
  13. exponent/commands/upgrade.py +29 -0
  14. exponent/commands/utils.py +236 -0
  15. exponent/core/config.py +180 -0
  16. exponent/core/graphql/__init__.py +0 -0
  17. exponent/core/graphql/client.py +59 -0
  18. exponent/core/graphql/cloud_config_queries.py +77 -0
  19. exponent/core/graphql/get_chats_query.py +47 -0
  20. exponent/core/graphql/github_config_queries.py +56 -0
  21. exponent/core/graphql/mutations.py +75 -0
  22. exponent/core/graphql/queries.py +110 -0
  23. exponent/core/graphql/subscriptions.py +452 -0
  24. exponent/core/remote_execution/checkpoints.py +212 -0
  25. exponent/core/remote_execution/cli_rpc_types.py +214 -0
  26. exponent/core/remote_execution/client.py +545 -0
  27. exponent/core/remote_execution/code_execution.py +58 -0
  28. exponent/core/remote_execution/command_execution.py +105 -0
  29. exponent/core/remote_execution/error_info.py +45 -0
  30. exponent/core/remote_execution/exceptions.py +10 -0
  31. exponent/core/remote_execution/file_write.py +410 -0
  32. exponent/core/remote_execution/files.py +415 -0
  33. exponent/core/remote_execution/git.py +268 -0
  34. exponent/core/remote_execution/languages/python_execution.py +239 -0
  35. exponent/core/remote_execution/languages/shell_streaming.py +221 -0
  36. exponent/core/remote_execution/languages/types.py +20 -0
  37. exponent/core/remote_execution/session.py +128 -0
  38. exponent/core/remote_execution/system_context.py +54 -0
  39. exponent/core/remote_execution/tool_execution.py +289 -0
  40. exponent/core/remote_execution/truncation.py +284 -0
  41. exponent/core/remote_execution/types.py +670 -0
  42. exponent/core/remote_execution/utils.py +600 -0
  43. exponent/core/types/__init__.py +0 -0
  44. exponent/core/types/command_data.py +206 -0
  45. exponent/core/types/event_types.py +89 -0
  46. exponent/core/types/generated/__init__.py +0 -0
  47. exponent/core/types/generated/strategy_info.py +225 -0
  48. exponent/migration-docs/login.md +112 -0
  49. exponent/py.typed +4 -0
  50. exponent/utils/__init__.py +0 -0
  51. exponent/utils/colors.py +92 -0
  52. exponent/utils/version.py +289 -0
  53. indent-0.0.8.dist-info/METADATA +36 -0
  54. indent-0.0.8.dist-info/RECORD +56 -0
  55. indent-0.0.8.dist-info/WHEEL +4 -0
  56. indent-0.0.8.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,211 @@
1
+ import asyncio
2
+ import json
3
+ from pathlib import Path
4
+ import subprocess
5
+ import sys
6
+ from uuid import uuid4
7
+ import webbrowser
8
+
9
+ import click
10
+ from git import GitCommandError, Repo
11
+
12
+ from exponent.commands.common import (
13
+ redirect_to_login,
14
+ verify_gh_app_installation,
15
+ )
16
+ from exponent.commands.settings import use_settings
17
+ from exponent.commands.types import exponent_cli_group
18
+ from exponent.core.config import Settings
19
+ from exponent.core.remote_execution.git import get_git_info
20
+
21
+
22
+ @exponent_cli_group()
23
+ def github_app_cli() -> None:
24
+ """Run AI-powered chat sessions."""
25
+ pass
26
+
27
+
28
+ @github_app_cli.command()
29
+ @use_settings
30
+ def install_github_app(
31
+ settings: Settings,
32
+ ) -> None:
33
+ """Start or reconnect to an Exponent session."""
34
+ if not settings.api_key:
35
+ redirect_to_login(settings)
36
+ return
37
+
38
+ loop = asyncio.get_event_loop()
39
+
40
+ api_key = settings.api_key
41
+ base_api_url = settings.get_base_api_url()
42
+ base_ws_url = settings.get_base_ws_url()
43
+
44
+ git_info = asyncio.run(get_git_info("."))
45
+ if not git_info:
46
+ raise RuntimeError("Not running inside of valid git repository")
47
+
48
+ install_url = "https://github.com/apps/indent-com/installations/new"
49
+ webbrowser.open(install_url)
50
+
51
+ click.confirm(
52
+ "Press enter once you've installed the github app.",
53
+ default=True,
54
+ abort=True,
55
+ prompt_suffix="",
56
+ )
57
+
58
+ click.secho("Verifying installation...", fg="yellow")
59
+ verified = loop.run_until_complete(
60
+ verify_gh_app_installation(api_key, base_api_url, base_ws_url, git_info)
61
+ )
62
+
63
+ if verified:
64
+ click.secho("Verified!", fg="green")
65
+ else:
66
+ click.secho("No verification found :(", fg="red")
67
+ sys.exit(1)
68
+
69
+ click.secho("Creating workflow file...", fg="yellow")
70
+ _create_workflow_yaml()
71
+
72
+
73
+ WORKFLOW_YAML = """
74
+ name: Indent Action
75
+
76
+ on:
77
+ issue_comment:
78
+ types: [created]
79
+ pull_request_review_comment:
80
+ types: [created]
81
+ issues:
82
+ types: [opened, assigned]
83
+ pull_request_review:
84
+ types: [submitted]
85
+
86
+ jobs:
87
+ indent:
88
+ if: |
89
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@indent')) ||
90
+ (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@indent')) ||
91
+ (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@indent')) ||
92
+ (github.event_name == 'issues' && (contains(github.event.issue.body, '@indent') || contains(github.event.issue.title, '@indent')))
93
+ runs-on: ubuntu-latest
94
+
95
+ steps:
96
+ - name: Generate token for Indent app
97
+ id: generate_token
98
+ uses: actions/create-github-app-token@v1
99
+ with:
100
+ app-id: ${{ secrets.INDENT_APP_ID }}
101
+ private-key: ${{ secrets.INDENT_APP_PRIVATE_KEY }}
102
+
103
+ - name: Respond to mention
104
+ uses: actions/github-script@v7
105
+ with:
106
+ github-token: ${{ steps.generate_token.outputs.token }}
107
+ script: |
108
+ const issue_number = context.payload.issue?.number ||
109
+ context.payload.pull_request?.number ||
110
+ context.payload.review?.pull_request?.number;
111
+
112
+ await github.rest.issues.createComment({
113
+ owner: context.repo.owner,
114
+ repo: context.repo.repo,
115
+ issue_number: issue_number,
116
+ body: 'Hi it\'s me, Indent'
117
+ });
118
+ """.lstrip()
119
+
120
+
121
+ def _create_workflow_yaml() -> None:
122
+ git_branch = f"indent-workflow-{uuid4()}"
123
+ workflow_file = "indent-review.yml"
124
+
125
+ # 1. Locate the repository (searches upward until it finds .git).
126
+ repo = Repo(Path.cwd(), search_parent_directories=True)
127
+ if repo.bare or not repo.working_tree_dir:
128
+ sys.exit("Error: cannot operate inside a bare repository.")
129
+
130
+ original_branch = repo.active_branch.name if not repo.head.is_detached else None
131
+
132
+ # 2. Create or reuse the branch, then check it out.
133
+ try:
134
+ branch_ref = repo.create_head(git_branch)
135
+ except GitCommandError:
136
+ branch_ref = repo.heads[git_branch]
137
+ branch_ref.checkout()
138
+
139
+ # 3. Ensure workflow directory exists.
140
+ wf_dir = Path(repo.working_tree_dir) / ".github" / "workflows"
141
+ wf_dir.mkdir(parents=True, exist_ok=True)
142
+
143
+ yml = wf_dir / workflow_file
144
+ if not yml.exists():
145
+ yml.write_text(WORKFLOW_YAML)
146
+ # 5. Stage & commit.
147
+ repo.index.add([str(yml)])
148
+ repo.index.commit("Add Indent workflow template")
149
+ print(
150
+ f"Created {yml.relative_to(repo.working_tree_dir)} "
151
+ f"on branch '{git_branch}'."
152
+ )
153
+ else:
154
+ print(
155
+ f"{yml.relative_to(repo.working_tree_dir)} already exists; nothing to do."
156
+ )
157
+
158
+ subprocess.run(
159
+ ["git", "push", "-u", "origin", git_branch],
160
+ cwd=repo.working_tree_dir,
161
+ check=True,
162
+ capture_output=False,
163
+ text=True,
164
+ )
165
+
166
+ pr_url: str | None = None
167
+ pr_title = f"Add Indent workflow ({workflow_file})"
168
+ pr_body = "This PR introduces an Indent github action workflow"
169
+
170
+ def run_gh(cmd: list[str]) -> subprocess.CompletedProcess[str]:
171
+ return subprocess.run(
172
+ ["gh", *cmd],
173
+ cwd=repo.working_tree_dir,
174
+ check=True,
175
+ capture_output=True,
176
+ text=True,
177
+ )
178
+
179
+ try:
180
+ # Does a PR already exist for this head branch?
181
+ result = run_gh(["pr", "view", git_branch, "--json", "url"])
182
+ pr_url = json.loads(result.stdout)["url"]
183
+ except subprocess.CalledProcessError:
184
+ # No PR yet → create one
185
+ base = original_branch or repo.remotes.origin.refs[0].name.split("/")[-1]
186
+ run_gh(
187
+ [
188
+ "pr",
189
+ "create",
190
+ "--head",
191
+ git_branch,
192
+ "--base",
193
+ base,
194
+ "--title",
195
+ pr_title,
196
+ "--body",
197
+ pr_body,
198
+ ]
199
+ )
200
+ # Fetch the newly created URL
201
+ result = run_gh(["pr", "view", git_branch, "--json", "url"])
202
+ pr_url = json.loads(result.stdout)["url"]
203
+
204
+ if pr_url:
205
+ click.secho(f"PR: {pr_url}", fg="green")
206
+ webbrowser.open(pr_url)
207
+ else:
208
+ click.secho("Failed to create PR!", fg="red")
209
+
210
+ if original_branch:
211
+ repo.git.checkout(original_branch)
@@ -0,0 +1,96 @@
1
+ import asyncio
2
+
3
+ import click
4
+ from websockets.exceptions import ConnectionClosed
5
+
6
+ from exponent.commands.settings import use_settings
7
+ from exponent.commands.shell_commands import LiveView
8
+ from exponent.commands.theme import get_theme
9
+ from exponent.commands.types import exponent_cli_group
10
+ from exponent.core.config import Settings
11
+ from exponent.core.graphql.client import GraphQLClient
12
+ from exponent.core.graphql.subscriptions import INDENT_CHAT_EVENT_STREAM_SUBSCRIPTION
13
+
14
+
15
+ @exponent_cli_group(hidden=True)
16
+ def listen_cli() -> None:
17
+ pass
18
+
19
+
20
+ @listen_cli.command()
21
+ @click.option("--chat-id", help="ID of the chat to listen to", required=True)
22
+ @click.option(
23
+ "--known-event-uuids",
24
+ help="Comma-separated list of known event UUIDs to skip",
25
+ default="",
26
+ )
27
+ @use_settings
28
+ def listen(settings: Settings, chat_id: str, known_event_uuids: str) -> None:
29
+ """Listen to events from an indent chat session."""
30
+ api_key = settings.api_key
31
+ if not api_key:
32
+ raise click.UsageError("API key is not set")
33
+
34
+ base_api_url = settings.get_base_api_url()
35
+ base_ws_url = settings.get_base_ws_url()
36
+
37
+ gql_client = GraphQLClient(api_key, base_api_url, base_ws_url)
38
+
39
+ # Parse known event UUIDs
40
+ known_uuids = []
41
+ if known_event_uuids:
42
+ known_uuids = [
43
+ uuid.strip() for uuid in known_event_uuids.split(",") if uuid.strip()
44
+ ]
45
+
46
+ # Set up the live view with theme
47
+ theme = get_theme(settings.options.use_default_colors)
48
+ live_view = LiveView(theme, render_user_messages=True)
49
+
50
+ asyncio.run(_listen(gql_client, chat_id, known_uuids, live_view))
51
+
52
+
53
+ async def _listen(
54
+ gql_client: GraphQLClient,
55
+ chat_id: str,
56
+ known_event_uuids: list[str],
57
+ live_view: LiveView,
58
+ ) -> None:
59
+ """Internal listen function that handles the WebSocket subscription."""
60
+ while True:
61
+ try:
62
+ variables = {
63
+ "chatUuid": chat_id,
64
+ "lastKnownFullEventUuid": known_event_uuids[-1]
65
+ if known_event_uuids
66
+ else None,
67
+ }
68
+
69
+ async for response in gql_client.subscribe(
70
+ INDENT_CHAT_EVENT_STREAM_SUBSCRIPTION, variables
71
+ ):
72
+ event = response["indentChatEventStream"]
73
+ kind = event["__typename"]
74
+
75
+ # Handle different event types
76
+ if kind in ["UserEvent", "AssistantEvent", "SystemEvent"]:
77
+ live_view.render_event(kind, event)
78
+ elif kind == "Error":
79
+ print(f"Error: {event.get('message', 'Unknown error')}")
80
+ elif kind == "UnauthenticatedError":
81
+ print(
82
+ f"Authentication error: {event.get('message', 'Unauthenticated')}"
83
+ )
84
+ break
85
+ else:
86
+ print(f"Unknown event type: {kind}")
87
+
88
+ except ConnectionClosed:
89
+ print("WebSocket disconnected, reconnecting...")
90
+ await asyncio.sleep(1)
91
+ except KeyboardInterrupt:
92
+ print("\nDisconnecting...")
93
+ break
94
+ except Exception as e:
95
+ print(f"Error: {e}")
96
+ await asyncio.sleep(1)
@@ -0,0 +1,208 @@
1
+ import asyncio
2
+ import sys
3
+ import time
4
+
5
+ import click
6
+
7
+ from exponent.commands.common import (
8
+ check_inside_git_repo,
9
+ check_running_from_home_directory,
10
+ check_ssl,
11
+ create_chat,
12
+ inside_ssh_session,
13
+ redirect_to_login,
14
+ start_client,
15
+ )
16
+ from exponent.commands.settings import use_settings
17
+ from exponent.commands.types import exponent_cli_group
18
+ from exponent.commands.utils import (
19
+ ConnectionTracker,
20
+ Spinner,
21
+ launch_exponent_browser,
22
+ print_exponent_message,
23
+ )
24
+ from exponent.core.config import Settings
25
+ from exponent.core.remote_execution.client import (
26
+ REMOTE_EXECUTION_CLIENT_EXIT_INFO,
27
+ SwitchCLIChat,
28
+ WSDisconnected,
29
+ )
30
+ from exponent.core.remote_execution.types import ChatSource
31
+ from exponent.core.remote_execution.utils import assert_unreachable
32
+ from exponent.utils.version import check_exponent_version_and_upgrade
33
+
34
+ try:
35
+ # this is an optional dependency for python <3.11
36
+ from async_timeout import timeout
37
+ except ImportError: # pragma: no cover
38
+ from asyncio import timeout
39
+
40
+
41
+ @exponent_cli_group()
42
+ def run_cli() -> None:
43
+ """Run AI-powered chat sessions."""
44
+ pass
45
+
46
+
47
+ @run_cli.command()
48
+ @click.option(
49
+ "--chat-id",
50
+ help="ID of an existing chat session to reconnect",
51
+ required=False,
52
+ prompt_required=False,
53
+ prompt="Enter the chat ID to reconnect to",
54
+ )
55
+ @click.option(
56
+ "--prompt",
57
+ help="Start a chat with a given prompt.",
58
+ )
59
+ @click.option(
60
+ "--workflow-id",
61
+ hidden=True,
62
+ required=False,
63
+ )
64
+ @use_settings
65
+ def run(
66
+ settings: Settings,
67
+ chat_id: str | None = None,
68
+ prompt: str | None = None,
69
+ workflow_id: str | None = None,
70
+ ) -> None:
71
+ """Start or reconnect to an Exponent session."""
72
+ check_exponent_version_and_upgrade(settings)
73
+
74
+ if not settings.api_key:
75
+ redirect_to_login(settings)
76
+ return
77
+
78
+ loop = asyncio.get_event_loop()
79
+
80
+ check_running_from_home_directory()
81
+ check_ssl()
82
+ loop.run_until_complete(check_inside_git_repo(settings))
83
+
84
+ api_key = settings.api_key
85
+ base_url = settings.base_url
86
+ base_api_url = settings.get_base_api_url()
87
+ base_ws_url = settings.get_base_ws_url()
88
+
89
+ chat_uuid = chat_id or loop.run_until_complete(
90
+ create_chat(api_key, base_api_url, base_ws_url, ChatSource.CLI_RUN)
91
+ )
92
+
93
+ if chat_uuid is None:
94
+ sys.exit(1)
95
+
96
+ if (
97
+ not prompt
98
+ and (not inside_ssh_session())
99
+ and (not workflow_id)
100
+ # If the user specified a chat ID, they probably don't want to re-launch the chat
101
+ and (not chat_id)
102
+ ):
103
+ # Open the chat in the browser
104
+ launch_exponent_browser(settings.environment, base_url, chat_uuid)
105
+
106
+ while True:
107
+ result = run_chat(loop, api_key, chat_uuid, settings, prompt, workflow_id)
108
+ if result is None or isinstance(result, WSDisconnected):
109
+ # NOTE: None here means that handle_connection_changes exited
110
+ # first. We should likely have a different message for this.
111
+ if result and result.error_message:
112
+ click.secho(f"Error: {result.error_message}", fg="red")
113
+ sys.exit(10)
114
+ else:
115
+ print("Disconnected upon user request, shutting down...")
116
+ break
117
+ elif isinstance(result, SwitchCLIChat):
118
+ chat_uuid = result.new_chat_uuid
119
+ print("\nSwitching chats...")
120
+ else:
121
+ assert_unreachable(result)
122
+
123
+
124
+ def run_chat(
125
+ loop: asyncio.AbstractEventLoop,
126
+ api_key: str,
127
+ chat_uuid: str,
128
+ settings: Settings,
129
+ prompt: str | None,
130
+ workflow_id: str | None,
131
+ ) -> REMOTE_EXECUTION_CLIENT_EXIT_INFO | None:
132
+ start_ts = time.time()
133
+ base_url = settings.base_url
134
+ base_api_url = settings.get_base_api_url()
135
+ base_ws_url = settings.get_base_ws_url()
136
+
137
+ print_exponent_message(base_url, chat_uuid)
138
+ print()
139
+
140
+ connection_tracker = ConnectionTracker()
141
+
142
+ client_fut = loop.create_task(
143
+ start_client(
144
+ api_key,
145
+ base_url,
146
+ base_api_url,
147
+ base_ws_url,
148
+ chat_uuid,
149
+ prompt=prompt,
150
+ workflow_id=workflow_id,
151
+ connection_tracker=connection_tracker,
152
+ )
153
+ )
154
+
155
+ conn_fut = loop.create_task(handle_connection_changes(connection_tracker, start_ts))
156
+
157
+ try:
158
+ done, _ = loop.run_until_complete(
159
+ asyncio.wait({client_fut, conn_fut}, return_when=asyncio.FIRST_COMPLETED)
160
+ )
161
+
162
+ if client_fut in done:
163
+ return client_fut.result()
164
+ else:
165
+ return None
166
+ finally:
167
+ for task in asyncio.all_tasks(loop):
168
+ task.cancel()
169
+
170
+ try:
171
+ loop.run_until_complete(asyncio.wait(asyncio.all_tasks(loop)))
172
+ except asyncio.CancelledError:
173
+ pass
174
+
175
+
176
+ async def handle_connection_changes(
177
+ connection_tracker: ConnectionTracker, start_ts: float
178
+ ) -> None:
179
+ try:
180
+ async with timeout(5):
181
+ assert await connection_tracker.next_change()
182
+ print(ready_message(start_ts))
183
+ except TimeoutError:
184
+ spinner = Spinner("Connecting...")
185
+ spinner.show()
186
+ assert await connection_tracker.next_change()
187
+ spinner.hide()
188
+ print(ready_message(start_ts))
189
+
190
+ while True:
191
+ assert not await connection_tracker.next_change()
192
+
193
+ print("Disconnected...", end="")
194
+ await asyncio.sleep(1)
195
+ spinner = Spinner("Reconnecting...")
196
+ spinner.show()
197
+ assert await connection_tracker.next_change()
198
+ spinner.hide()
199
+ print("\x1b[1;32m✓ Reconnected", end="")
200
+ sys.stdout.flush()
201
+ await asyncio.sleep(1)
202
+ print("\r\x1b[0m\x1b[2K", end="")
203
+ sys.stdout.flush()
204
+
205
+
206
+ def ready_message(start_ts: float) -> str:
207
+ elapsed = round(time.time() - start_ts, 2)
208
+ return f"\x1b[32m✓\x1b[0m Ready in {elapsed}s"
@@ -0,0 +1,56 @@
1
+ from collections.abc import Callable
2
+ from functools import wraps
3
+ from typing import Any
4
+
5
+ import click
6
+
7
+ from exponent.commands.utils import (
8
+ print_editable_install_forced_prod_warning,
9
+ print_editable_install_warning,
10
+ )
11
+ from exponent.core.config import (
12
+ Environment,
13
+ get_settings,
14
+ is_editable_install,
15
+ )
16
+
17
+
18
+ def use_settings(f: Callable[..., Any]) -> Callable[..., Any]:
19
+ @click.option(
20
+ "--prod",
21
+ is_flag=True,
22
+ hidden=True,
23
+ help="Use production URLs even if in editable mode",
24
+ )
25
+ @click.option(
26
+ "--staging",
27
+ is_flag=True,
28
+ hidden=True,
29
+ help="Use staging URLs even if in editable mode",
30
+ )
31
+ @wraps(f)
32
+ def decorated_function(*args: Any, **kwargs: Any) -> Any:
33
+ prod = kwargs.pop("prod", False)
34
+ staging = kwargs.pop("staging", False)
35
+ settings = get_settings(use_prod=prod, use_staging=staging)
36
+
37
+ if is_editable_install() and not (prod or staging):
38
+ assert settings.environment == Environment.development
39
+ print_editable_install_warning(settings)
40
+
41
+ return f(*args, settings=settings, **kwargs)
42
+
43
+ return decorated_function
44
+
45
+
46
+ def use_prod_settings(f: Callable[..., Any]) -> Callable[..., Any]:
47
+ @wraps(f)
48
+ def decorated_function(*args: Any, **kwargs: Any) -> Any:
49
+ settings = get_settings(use_prod=True)
50
+
51
+ if is_editable_install():
52
+ print_editable_install_forced_prod_warning(settings)
53
+
54
+ return f(*args, settings=settings, **kwargs)
55
+
56
+ return decorated_function