diff --git a/README.md b/README.md index 80c05a8..f708101 100644 --- a/README.md +++ b/README.md @@ -151,12 +151,13 @@ No user intervention required - complete end-to-end. All configuration and deplo ## Usage -The CLI has five main commands: +The CLI has six main commands: - `devin-cli auth` - Manage authentication - `devin-cli create` - Create Devin sessions - `devin-cli get` - Get details of existing Devin sessions - `devin-cli message` - Send messages to existing Devin sessions +- `devin-cli upload` - Upload files for use in Devin sessions - `devin-cli setup` - Download workflow templates and guides ### Creating Sessions @@ -244,6 +245,42 @@ The `message` command: - Confirms successful message delivery - Returns message ID and session status +### Uploading Files + +Use the `upload` command to upload files to Devin for use in session prompts: + +```bash +# Upload a file (displays table format with usage instructions) +devin-cli upload /path/to/file.py + +# Upload and get JSON response +devin-cli upload /path/to/file.csv --output json + +# Upload and display ready-to-use format +devin-cli upload /path/to/data.json --format-prompt +``` + +The `upload` command: +- Accepts a file path as a required argument +- Returns the attachment URL from the API +- Displays instructions on how to use the file in prompts +- Supports various file types (code, text, CSV, JSON, images, etc.) + +**Using uploaded files in session prompts:** + +After uploading a file, include it in your session prompt using the ATTACHMENT format: + +```bash +# Create a session with an attached file +devin-cli create --prompt "Review this code file + +ATTACHMENT:\"https://storage.devin.ai/attachments/abc123/file.py\" + +Please analyze it for potential bugs." +``` + +**Important:** The ATTACHMENT line must be on its own line in the prompt for Devin to recognize it. + ### Setting Up Templates The `setup` command downloads the latest workflow templates and session guide to your repository: @@ -306,6 +343,11 @@ For the `devin-cli message` command: - `--message, -m`: The message to send (will be prompted if not provided) - `--output, -o`: Output format (`json` or `table`, default: `table`) +For the `devin-cli upload` command: +- `FILE_PATH`: Path to the file to upload (required) +- `--output, -o`: Output format (`json` or `table`, default: `table`) +- `--format-prompt`: Display the ready-to-use ATTACHMENT: format + ### Global Options - `--version`: Show version information @@ -355,6 +397,19 @@ devin-cli get devin-session-123 --output json # Get details as JSON devin-cli message devin-session-123 # Send message interactively devin-cli message devin-session-123 --message "Please continue with the implementation" +# Upload examples +devin-cli upload requirements.txt # Upload a file +devin-cli upload data.csv --output json # Get JSON output +devin-cli upload config.json --format-prompt # Show ready-to-use format + +# Combined workflow: upload file and create session +URL=$(devin-cli upload analysis.py --output json | jq -r '.url') +devin-cli create --prompt "Review this code + +ATTACHMENT:\"$URL\" + +Check for security issues." + # Setup examples devin-cli setup # Download templates to current directory devin-cli setup --force # Overwrite existing files diff --git a/devin_cli.py b/devin_cli.py index 6d12cac..792270c 100755 --- a/devin_cli.py +++ b/devin_cli.py @@ -166,6 +166,36 @@ def send_message_to_session(session_id: str, payload: dict) -> dict: raise DevinAPIError(f"API request failed: {e}") +def upload_file(file_path: str) -> str: + """Upload a file to Devin attachments API and return the URL""" + api_key = get_api_key() + + if not os.path.exists(file_path): + raise DevinAPIError(f"File not found: {file_path}") + + headers = { + 'Authorization': f'Bearer {api_key}' + } + + try: + with open(file_path, 'rb') as f: + files = {'file': f} + response = requests.post( + 'https://api.devin.ai/v1/attachments', + headers=headers, + files=files, + timeout=60 + ) + response.raise_for_status() + + url = response.text.strip() + if url.startswith('"') and url.endswith('"'): + url = url[1:-1] + return url + except requests.exceptions.RequestException as e: + raise DevinAPIError(f"File upload failed: {e}") + + def parse_list_input(value: str) -> List[str]: """Parse comma-separated string into list""" if not value: @@ -379,6 +409,41 @@ def message(session_id, message, output): click.echo(f"โŒ Unexpected error: {e}", err=True) sys.exit(1) +@cli.command() +@click.argument('file_path', type=click.Path(exists=True)) +@click.option('--output', '-o', type=click.Choice(['json', 'table']), default='table', help='Output format') +@click.option('--format-prompt', is_flag=True, help='Output the ready-to-use ATTACHMENT: format') +def upload(file_path, output, format_prompt): + """Upload a file to Devin for use in session prompts""" + + try: + click.echo(f"Uploading file: {file_path}...") + url = upload_file(file_path) + + if output == 'json': + result = {'url': url} + if format_prompt: + result['formatted'] = f'ATTACHMENT:"{url}"' + click.echo(json.dumps(result, indent=2)) + else: + click.echo("\nโœ… File uploaded successfully!") + click.echo(f"URL: {url}") + click.echo("\n๐Ÿ“‹ To use this file in a Devin session prompt, include:") + click.echo(f'ATTACHMENT:"{url}"') + + if format_prompt: + click.echo("\n๐Ÿ“ Ready-to-use format:") + click.echo(f'ATTACHMENT:"{url}"') + + except DevinAPIError as e: + click.echo(f"โŒ Error: {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"โŒ Unexpected error: {e}", err=True) + sys.exit(1) + + + @cli.command() @click.option('--target-dir', '-t', default='.', help='Target directory to copy files to (default: current directory)') diff --git a/test_cli.py b/test_cli.py index 7c87b3f..bd598a8 100644 --- a/test_cli.py +++ b/test_cli.py @@ -1031,6 +1031,116 @@ def test_get_and_message_api_error_handling(): print("โœ… API error handling for new commands works correctly") +def test_upload_help(): + """Test upload subcommand help""" + print("๐Ÿงช Testing upload --help command...") + result = run_cli_command(['upload', '--help']) + + assert result['returncode'] == 0, f"Upload help command failed: {result['stderr']}" + assert 'Upload a file to Devin' in result['stdout'], "Upload help text missing" + assert '--output' in result['stdout'], "Output option missing from upload help" + assert '--format-prompt' in result['stdout'], "Format-prompt option missing from upload help" + print("โœ… Upload help command works correctly") + + +def test_upload_file_function(): + """Test upload_file function with mocked API""" + print("๐Ÿงช Testing upload_file function...") + + sys.path.insert(0, os.path.dirname(__file__)) + from devin_cli import upload_file + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f: + f.write("Test content") + test_file = f.name + + try: + with patch('devin_cli.requests.post') as mock_post: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = '"https://storage.devin.ai/attachments/test-id/file.txt"' + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + with patch('devin_cli.get_api_key', return_value='test-key'): + url = upload_file(test_file) + + assert mock_post.call_count == 1, "API should have been called once" + call_kwargs = mock_post.call_args[1] + assert call_kwargs['headers']['Authorization'] == 'Bearer test-key' + assert 'files' in call_kwargs + assert call_kwargs['timeout'] == 60 + + assert url == 'https://storage.devin.ai/attachments/test-id/file.txt' + assert not url.startswith('"'), "URL should not have quotes" + + print("โœ… Upload file function works correctly") + finally: + os.unlink(test_file) + + +def test_upload_file_not_found(): + """Test upload_file with non-existent file""" + print("๐Ÿงช Testing upload with non-existent file...") + + sys.path.insert(0, os.path.dirname(__file__)) + from devin_cli import upload_file, DevinAPIError + + with patch('devin_cli.get_api_key', return_value='test-key'): + try: + upload_file('/nonexistent/file.txt') + assert False, "Should have raised DevinAPIError" + except DevinAPIError as e: + assert 'File not found' in str(e) + + print("โœ… File not found error handling works correctly") + + +def test_upload_api_error(): + """Test upload_file with API error""" + print("๐Ÿงช Testing upload with API error...") + + sys.path.insert(0, os.path.dirname(__file__)) + from devin_cli import upload_file, DevinAPIError + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f: + f.write("Test content") + test_file = f.name + + try: + with patch('devin_cli.requests.post') as mock_post: + mock_post.side_effect = requests.exceptions.RequestException("API Error") + + with patch('devin_cli.get_api_key', return_value='test-key'): + try: + upload_file(test_file) + assert False, "Should have raised DevinAPIError" + except DevinAPIError as e: + assert 'File upload failed' in str(e) + + print("โœ… API error handling works correctly") + finally: + os.unlink(test_file) + + +def test_upload_command_cli_execution(): + """Test upload command via CLI""" + print("๐Ÿงช Testing upload command CLI execution...") + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f: + f.write("Test content for CLI") + test_file = f.name + + try: + result = run_cli_command(['upload', '--help']) + assert result['returncode'] == 0, "Upload help should work" + assert 'Upload a file' in result['stdout'] + + print("โœ… Upload command CLI execution works correctly") + finally: + os.unlink(test_file) + + def run_all_tests(): """Run all tests""" print("๐Ÿš€ Starting comprehensive CLI tests...\n") @@ -1087,6 +1197,12 @@ def run_all_tests(): # Integration test_api_request_structure, test_end_to_end_workflow, + + test_upload_help, + test_upload_file_function, + test_upload_file_not_found, + test_upload_api_error, + test_upload_command_cli_execution, ] passed = 0