das-cli 1.2.10__py3-none-any.whl → 1.2.12__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.
das/cli.py CHANGED
@@ -1,1245 +1,1245 @@
1
- import sys
2
- import click
3
- import json
4
- import os
5
- from pathlib import Path
6
- from das.common.config import (
7
- save_api_url, load_api_url, DEFAULT_BASE_URL,
8
- save_verify_ssl, load_verify_ssl, VERIFY_SSL,
9
- load_openai_api_key, save_openai_api_key, clear_openai_api_key,
10
- clear_token, _config_dir
11
- )
12
-
13
- from das.app import Das
14
- from das.managers.download_manager import DownloadManager
15
- from das.managers.entries_manager import EntryManager
16
- from das.managers.search_manager import SearchManager
17
- from das.managers.digital_objects_manager import DigitalObjectsManager
18
- from das.common.file_utils import load_file_based_on_extension, parse_data_string
19
- from das.ai.plugins.dasai import DasAI
20
-
21
- # Simple table formatting function to avoid external dependencies
22
- def format_table(data, headers):
23
- """Format data into a text-based table without external dependencies"""
24
- if not data:
25
- return "No data to display"
26
-
27
- # Calculate column widths
28
- col_widths = [max(len(str(h)), max([len(str(row[i])) for row in data] or [0])) for i, h in enumerate(headers)]
29
-
30
- # Create the separator line
31
- separator = '+' + '+'.join('-' * (w + 2) for w in col_widths) + '+'
32
-
33
- # Create the header row
34
- header_row = '|' + '|'.join(' ' + str(h).ljust(w) + ' ' for h, w in zip(headers, col_widths)) + '|'
35
-
36
- # Create the data rows
37
- data_rows = []
38
- for row in data:
39
- data_rows.append('|' + '|'.join(' ' + str(cell).ljust(w) + ' ' for cell, w in zip(row, col_widths)) + '|')
40
-
41
- # Assemble the table
42
- table = [separator, header_row, separator]
43
- table.extend(data_rows)
44
- table.append(separator)
45
-
46
- return '\n'.join(table)
47
-
48
- class DasCLI:
49
- def __init__(self):
50
- self.client = None
51
- self.api_url = None
52
- self.entry_manager = None
53
- self.search_manager = None
54
- self.download_manager = None
55
- self.digital_objects_manager = None
56
- self.das_ai = None
57
-
58
- def get_client(self):
59
- """Get DAS client instance, using saved API URL"""
60
- if not self.api_url:
61
- self.api_url = load_api_url() or DEFAULT_BASE_URL
62
- if not self.api_url:
63
- raise click.UsageError(
64
- "No API URL configured. Please login first with 'das login --api-url <URL>'"
65
- )
66
-
67
- self.entry_manager = EntryManager()
68
- self.search_manager = SearchManager()
69
- self.download_manager = DownloadManager()
70
- self.digital_objects_manager = DigitalObjectsManager()
71
- # Set SSL verification based on saved config
72
- if not self.client:
73
- self.client = Das(self.api_url)
74
-
75
- return self.client
76
-
77
- def get_das_ai(self):
78
- """Get DAS AI instance"""
79
- if not self.das_ai:
80
- self.das_ai = DasAI()
81
- return self.das_ai
82
-
83
- pass_das_context = click.make_pass_decorator(DasCLI, ensure=True)
84
-
85
- @click.group()
86
- @click.version_option(package_name="das-cli")
87
- @click.pass_context
88
- def cli(ctx):
89
- """DAS Python CLI - Data Archive System client tool"""
90
- ctx.obj = DasCLI()
91
-
92
- @cli.command()
93
- @click.option('--api-url', required=True, help='API base URL')
94
- @click.option('--username', required=True, prompt=True, help='Username')
95
- @click.option('--password', required=True, prompt=True, hide_input=True, help='Password')
96
- @pass_das_context
97
- def login(das_ctx, api_url, username, password):
98
- """Login and store authentication token"""
99
- # Save API URL for future use
100
- save_api_url(api_url)
101
- das_ctx.api_url = api_url
102
-
103
- # Authenticate
104
- client = das_ctx.get_client()
105
- token = client.authenticate(username, password)
106
- if not token:
107
- click.secho("❌ Authentication failed. Please check your credentials.", fg="red")
108
- return
109
- else:
110
- click.secho("✓ Authentication successful!", fg="green")
111
-
112
- # Search commands group
113
- @cli.group()
114
- def search():
115
- """Commands for searching entries"""
116
- pass
117
-
118
- @search.command("help")
119
- def search_help():
120
- """Show help about search query syntax and formats"""
121
- click.secho("\nSearch Query Syntax Help", fg="green", bold=True)
122
- click.echo("=" * 50)
123
-
124
- click.secho("\nBasic Search:", fg="blue", bold=True)
125
- click.echo(" das search entries --attribute <AttributeName> --query '<SearchQuery>'")
126
- click.echo(" Example: das search entries --attribute Cores --query 'name(*64*)'")
127
-
128
- click.secho("\nOutput Formats:", fg="blue", bold=True)
129
- click.echo(" --format table Format results in a nice table (default)")
130
- click.echo(" --format json Return results as JSON")
131
- click.echo(" --format compact Simple one-line-per-result format")
132
- click.echo(" --raw Show raw API response")
133
-
134
- click.secho("\nQuery Syntax Examples:", fg="blue", bold=True)
135
- click.echo(" 'name(*pattern*)' Search for pattern in name")
136
- click.echo(" 'name(*pattern*);code(*ABC*)' Multiple conditions (AND)")
137
- click.echo(" 'Create at(>2023-01-01)' Date comparison")
138
-
139
- click.secho("\nGet Detailed Information:", fg="blue", bold=True)
140
- click.echo(" das search entry <ID> Get detailed info for a single entry")
141
- click.echo(" Example: das search entry 6b0e68e6-00cd-43a7-9c51-d56c9c091123")
142
-
143
- click.secho("\nPagination:", fg="blue", bold=True)
144
- click.echo(" --page <num> Show specific page of results")
145
- click.echo(" --max-results <n> Number of results per page (default: 10)")
146
-
147
- click.secho("\nSorting:", fg="blue", bold=True)
148
- click.echo(" --sort-by <field> Field to sort by (default: Name)")
149
- click.echo(" --sort-order <order> Sort order: 'asc' or 'desc' (default: asc)")
150
- click.echo("\n")
151
-
152
- @search.command("entries")
153
- @click.option('--attribute', required=True, help='Attribute name to search in')
154
- @click.option('--query', required=True, help='Search query string')
155
- @click.option('--max-results', default=10, help='Maximum number of results to return')
156
- @click.option('--page', default=1, help='Page number for paginated results')
157
- @click.option('--sort-by', default='Name', help='Field to sort by (default: Name)')
158
- @click.option('--sort-order', default='asc', type=click.Choice(['asc', 'desc']), help='Sort order (asc or desc)')
159
- @click.option('--format', 'output_format', default='table', type=click.Choice(['table', 'json', 'compact']), help='Output format (table, json, or compact)')
160
- @click.option('--raw', is_flag=True, help='Show raw response from API')
161
- @pass_das_context
162
- def search_entries(das_ctx, attribute, query, max_results, page, sort_by, sort_order, output_format, raw):
163
- """Search entries based on attribute and query"""
164
- try:
165
- # Ensure client and search_manager are initialized
166
- das_ctx.get_client()
167
-
168
- results = das_ctx.search_manager.search_entries(
169
- attribute=attribute,
170
- query=query,
171
- max_results=max_results,
172
- page=page,
173
- sort_by=sort_by,
174
- sort_order=sort_order
175
- )
176
-
177
- if raw:
178
- click.echo(results)
179
- return
180
-
181
- total_count = results.get('totalCount', 0)
182
- items = results.get('items', [])
183
-
184
- # Display search summary
185
- click.secho(f"\nFound {total_count} results for query: '{query}' in attribute: '{attribute}'", fg="blue")
186
- click.secho(f"Showing results {(page-1)*max_results + 1}-{min(page*max_results, total_count)} of {total_count}\n", fg="blue")
187
-
188
- if not items:
189
- click.secho("No results found.", fg="yellow")
190
- return
191
-
192
- if output_format == 'json':
193
- click.echo(json.dumps(results, indent=2))
194
- elif output_format == 'compact':
195
- for i, item in enumerate(items, 1):
196
- entry = item.get('entry', {})
197
- code = entry.get('code', 'N/A')
198
- name = entry.get('displayname', 'Unnamed')
199
- description = entry.get('description', 'No description')
200
- click.echo(f"{i}. {name} (Code: {code}) - {description}")
201
- else: # table format
202
- headers = ["#", "Code", "Name", "Description", "Owner"]
203
- table_data = []
204
-
205
- for i, item in enumerate(items, 1):
206
- entry = item.get('entry', {})
207
- table_data.append([
208
- i,
209
- entry.get('code', 'N/A'),
210
- entry.get('displayname', 'Unnamed'),
211
- entry.get('description', 'No description')[:50] + ('...' if entry.get('description', '') and len(entry.get('description', '')) > 50 else ''),
212
- entry.get('owner', 'Unknown')
213
- ])
214
-
215
- click.echo(format_table(table_data, headers))
216
-
217
- except Exception as e:
218
- click.secho(f"Error: {e}", fg="red")
219
-
220
- # Hangfire commands group
221
- @search.command("entry")
222
- @click.argument('entry_id', required=True)
223
- @click.option('--format', 'output_format', default='table', type=click.Choice(['table', 'json']), help='Output format (table or json)')
224
- @pass_das_context
225
- def get_entry(das_ctx, entry_id, output_format):
226
- """Get detailed information about a single entry by ID"""
227
- try:
228
- # Ensure client and entry_manager are initialized
229
- das_ctx.get_client()
230
- # Reusing the entry manager to get entry details by ID
231
- entry = das_ctx.entry_manager.get_entry(entry_id)
232
-
233
- if output_format == 'json':
234
- click.echo(json.dumps(entry, indent=2))
235
- else:
236
- click.secho(f"\nEntry Details (ID: {entry_id})", fg="blue", bold=True)
237
- click.echo("=" * 50)
238
-
239
- if not entry or not isinstance(entry, dict):
240
- click.secho("No entry found with the specified ID.", fg="yellow")
241
- return
242
-
243
- # Format display fields in a readable way
244
- formatted_data = []
245
- for key, value in sorted(entry.items()):
246
- # Skip empty or None values
247
- if value is None or value == '':
248
- continue
249
-
250
- # Format date fields
251
- if isinstance(value, str) and key.lower().endswith(('time', 'date')) and 'T' in value:
252
- try:
253
- date_part = value.split('T')[0]
254
- time_part = value.split('T')[1].split('.')[0]
255
- value = f"{date_part} {time_part}"
256
- except:
257
- pass # Keep original if formatting fails
258
-
259
- # Add to formatted data
260
- formatted_data.append((key, value))
261
-
262
- # Display as key-value pairs
263
- for key, value in formatted_data:
264
- click.echo(f"{key.ljust(25)}: {value}")
265
-
266
- except Exception as e:
267
- click.secho(f"Error: {e}", fg="red")
268
-
269
- @cli.group()
270
- def hangfire():
271
- """Commands for working with Hangfire tasks"""
272
- pass
273
-
274
- @hangfire.command("sync-doi")
275
- @click.argument('id', required=True)
276
- @pass_das_context
277
- def sync_doi(das_ctx, id):
278
- """Trigger a DOI synchronization task by ID"""
279
- client = das_ctx.get_client()
280
- try:
281
- client.hangfire.sync_doi(id)
282
- click.secho(f"✓ DOI synchronization task triggered!", fg="green")
283
- except Exception as e:
284
- click.secho(f"Error: {e}", fg="red")
285
-
286
- # Entries commands group
287
- @cli.group()
288
- def entry():
289
- """Commands for working with entries"""
290
- pass
291
-
292
- @entry.command("update")
293
- @click.option('--id', default=None, help='Entry ID to update (only used with --data for single entry)')
294
- @click.option('--code', default=None, help='Entry code to update (only used with --data for single entry)')
295
- @click.argument('file_path', required=False)
296
- @click.option('--data', help='Data string in format { "key1": "value1", "key2": "value2", ... } or a list of such objects')
297
- @pass_das_context
298
- def update_entry(das_ctx, id, code, file_path, data):
299
- """Update entries from file or data string by ID or code.
300
-
301
- For single updates, provide either --id or --code (or Id/Code in the data).
302
- For bulk updates, each entry must contain a Code or Id field.
303
- Files can contain multiple entries (rows in CSV/XLS or list of objects in JSON).
304
-
305
- Examples:
306
-
307
- \b
308
- # Update by code from data string
309
- das entry update --code ENT001 --data "{ 'Grant Public Access': 'Yes' }"
310
-
311
- \b
312
- # Update by ID from data string
313
- das entry update --id 9b826efc-36f3-471c-ad18-9c1e00abe4fa --data "{ 'Name': 'Updated' }"
314
-
315
- \b
316
- # Update entries from JSON file (each object needs Code or Id)
317
- das entry update c:\\data\\entries.json
318
-
319
- \b
320
- # Update entries from CSV file
321
- das entry update c:\\data\\entries.csv
322
-
323
- \b
324
- # Update multiple entries from data string
325
- das entry update --data "[{ 'Code': 'ENT001', ... }, { 'Code': 'ENT002', ... }]"
326
- """
327
- try:
328
- # Ensure client and entry_manager are initialized
329
- das_ctx.get_client()
330
- if not file_path and not data:
331
- raise click.UsageError("Please provide either a file path or data string")
332
-
333
- entry_data = None
334
- is_bulk_update = False
335
-
336
- if file_path:
337
- click.echo(f"Loading data from file: {file_path}")
338
- entry_data = load_file_based_on_extension(file_path)
339
-
340
- # Check if we got a list or a single object
341
- if isinstance(entry_data, list):
342
- is_bulk_update = True
343
- click.echo(f"Found {len(entry_data)} entries to update")
344
- else:
345
- # If we got a single object but no id/code was provided, check if it has Id or Code in data
346
- if not id and not code:
347
- data_id = next((entry_data.get(key) for key in entry_data if key.lower() == 'id'), None)
348
- data_code = next((entry_data.get(key) for key in entry_data if key.lower() == 'code'), None)
349
- if not data_id and not data_code:
350
- raise click.UsageError("No --id or --code provided and entry data doesn't contain an Id or Code field")
351
- id = id or data_id
352
- code = code or data_code
353
-
354
- elif data:
355
- click.echo("Parsing data string")
356
- entry_data = parse_data_string(data)
357
-
358
- # Check if we got a list or a single object
359
- if isinstance(entry_data, list):
360
- is_bulk_update = True
361
- click.echo(f"Found {len(entry_data)} entries to update")
362
- elif not id and not code:
363
- # If we got a single object but no id/code was provided, check if it has Id or Code in data
364
- data_id = next((entry_data.get(key) for key in entry_data if key.lower() == 'id'), None)
365
- data_code = next((entry_data.get(key) for key in entry_data if key.lower() == 'code'), None)
366
- if not data_id and not data_code:
367
- raise click.UsageError("No --id or --code provided and data doesn't contain an Id or Code field")
368
- id = id or data_id
369
- code = code or data_code
370
-
371
- if not entry_data:
372
- raise click.UsageError("No valid entry data found")
373
-
374
- # For single entry update, require id or code
375
- if not is_bulk_update and not id and not code:
376
- raise click.UsageError("Please provide either --id or --code, or include Id or Code in the data")
377
-
378
- # Update the entries
379
- if is_bulk_update:
380
- results = das_ctx.entry_manager.update(entries=entry_data)
381
-
382
- # Display results
383
- success_count = sum(1 for result in results if result.get('status') == 'success')
384
- error_count = len(results) - success_count
385
-
386
- if success_count > 0:
387
- click.secho(f"✓ Successfully updated {success_count} entries", fg="green")
388
-
389
- if error_count > 0:
390
- click.secho(f"✗ Failed to update {error_count} entries", fg="red")
391
-
392
- # Show details of the failures
393
- click.echo("\nFailed updates:")
394
- for result in results:
395
- if result.get('status') == 'error':
396
- ident = result.get('code') or result.get('id') or 'Unknown'
397
- click.echo(f" {ident}: {result.get('error', 'Unknown error')}")
398
- else:
399
- # Single entry update
400
- results = das_ctx.entry_manager.update(id=id, code=code, entry=entry_data)
401
-
402
- if results and results[0].get('status') == 'success':
403
- identifier = id or code
404
- id_label = "ID" if id else "code"
405
- click.secho(f"✓ Entry '{identifier}' ({id_label}) updated successfully!", fg="green")
406
- click.echo(f"Entry ID: {results[0].get('id')}")
407
- else:
408
- error_msg = results[0].get('error', 'No response from server') if results else 'No response from server'
409
- click.secho(f"Entry update failed: {error_msg}", fg="red")
410
-
411
- except Exception as e:
412
- click.secho(f"Error: {e}", fg="red")
413
-
414
- @entry.command("delete")
415
- @click.option('--code', default=None, help='Entry code')
416
- @click.option('--id', default=None, help='Entry ID')
417
- @click.option('--force', is_flag=True, help='Skip confirmation prompt')
418
- @pass_das_context
419
- def delete_entry(das_ctx, code, id, force):
420
- """Delete an entry by its code or ID"""
421
- if not code and not id:
422
- raise click.UsageError("Please provide either an entry code or ID")
423
-
424
- # Ensure client and entry_manager are initialized
425
- das_ctx.get_client()
426
-
427
- identifier = code or id
428
- id_label = "code" if code else "ID"
429
-
430
- if not force:
431
- if not click.confirm(f"Are you sure you want to delete entry with {id_label} '{identifier}'?"):
432
- click.echo("Operation cancelled.")
433
- return
434
-
435
- try:
436
- das_ctx.entry_manager.delete(id=id, code=code)
437
- click.secho(f"✓ Entry with {id_label} '{identifier}' deleted!", fg="green")
438
- except Exception as e:
439
- click.secho(f"Error: {e}", fg="red")
440
-
441
- @entry.command("link-digital-objects")
442
- @click.option('--entry-code', required=True, help='Entry code to link/unlink digital objects to')
443
- @click.option('--digital-object-code', '-d', multiple=True, required=True, help='Digital object code. Use multiple times for multiple objects.')
444
- @click.option('--unlink', is_flag=True, help='Unlink specified digital objects from the entry instead of linking')
445
- @pass_das_context
446
- def link_digital_objects(das_ctx, entry_code, digital_object_code, unlink):
447
- """Link or unlink existing digital objects to an entry by their codes.
448
-
449
- Examples:
450
-
451
- \b
452
- # Link two digital objects to an entry
453
- das entry link-digital-objects --entry-code ENT001 -d DO001 -d DO002
454
-
455
- \b
456
- # Unlink a digital object from an entry
457
- das entry link-digital-objects --entry-code ENT001 -d DO003 --unlink
458
- """
459
- try:
460
- das_ctx.get_client()
461
- codes = list(digital_object_code)
462
- if not codes:
463
- raise click.UsageError("Please provide at least one --digital-object-code")
464
-
465
- success = das_ctx.digital_objects_manager.link_existing_digital_objects(
466
- entry_code=entry_code,
467
- digital_object_code_list=codes,
468
- is_unlink=unlink,
469
- )
470
-
471
- if success:
472
- action = "unlinked" if unlink else "linked"
473
- click.secho(f"✓ Successfully {action} {len(codes)} digital object(s) for entry '{entry_code}'", fg="green")
474
- else:
475
- click.secho("Operation did not report success.", fg="yellow")
476
- except Exception as e:
477
- click.secho(f"Error: {e}", fg="red")
478
-
479
- @entry.command("create")
480
- @click.option('--attribute', required=True, help='Attribute name')
481
- @click.argument('file_path', required=False)
482
- @click.option('--data', help='Data string in format { "key1": "value1", "key2": "value2", ... } or a list of such objects')
483
- @pass_das_context
484
- def create_entry(das_ctx, attribute, file_path=None, data=None):
485
- """Create one or more entries from file or data string
486
-
487
- Files can contain multiple entries (rows in CSV/XLS or list of objects in JSON).
488
-
489
- Examples:
490
-
491
- \b
492
- # Create entries from JSON file
493
- das entry create --attribute core c:\\data\\entries.json
494
-
495
- \b
496
- # Create entries from CSV file
497
- das entry create --attribute core c:\\data\\entries.csv
498
-
499
- \b
500
- # Create entries from Excel file
501
- das entry create --attribute core c:\\data\\entries.xls
502
-
503
- \b
504
- # Create a single entry from data string
505
- das entry create --attribute core --data { 'Grant Public Access': Yes, ... }
506
-
507
- \b
508
- # Create multiple entries from data string
509
- das entry create --attribute core --data [{ 'Name': 'Entry 1', ... }, { 'Name': 'Entry 2', ... }]
510
- """
511
- try:
512
- # Ensure client and entry_manager are initialized
513
- das_ctx.get_client()
514
- if not file_path and not data:
515
- raise click.UsageError("Please provide either a file path or data string")
516
-
517
- entry_data = None
518
- is_bulk_create = False
519
-
520
- if file_path:
521
- click.echo(f"Loading data from file: {file_path}")
522
- entry_data = load_file_based_on_extension(file_path)
523
-
524
- # Check if we got a list or a single object
525
- if isinstance(entry_data, list):
526
- is_bulk_create = True
527
- click.echo(f"Found {len(entry_data)} entries to create")
528
-
529
- elif data:
530
- click.echo("Parsing data string")
531
- entry_data = parse_data_string(data)
532
-
533
- # Check if we got a list or a single object
534
- if isinstance(entry_data, list):
535
- is_bulk_create = True
536
- click.echo(f"Found {len(entry_data)} entries to create")
537
-
538
- if not entry_data:
539
- raise click.UsageError("No valid entry data found")
540
-
541
- # Create the entries
542
- if is_bulk_create:
543
- results = das_ctx.entry_manager.create(attribute=attribute, entries=entry_data)
544
-
545
- # Display results
546
- success_count = sum(1 for result in results if result.get('status') == 'success')
547
- error_count = len(results) - success_count
548
-
549
- if success_count > 0:
550
- click.secho(f"✓ Successfully created {success_count} entries", fg="green")
551
-
552
- # Show IDs of created entries
553
- click.echo("\nCreated entry IDs:")
554
- for result in results:
555
- if result.get('status') == 'success':
556
- click.echo(f" {result.get('id')}")
557
-
558
- if error_count > 0:
559
- click.secho(f"✗ Failed to create {error_count} entries", fg="red")
560
-
561
- # Show details of the failures
562
- click.echo("\nFailed creations:")
563
- for i, result in enumerate(results):
564
- if result.get('status') == 'error':
565
- click.echo(f" Entry #{i+1}: {result.get('error', 'Unknown error')}")
566
- else:
567
- # Single entry creation
568
- results = das_ctx.entry_manager.create(attribute=attribute, entry=entry_data)
569
-
570
- if results and results[0].get('status') == 'success':
571
- click.secho(f"✓ Entry created successfully!", fg="green")
572
- click.echo(f"Entry ID: {results[0].get('id')}")
573
- else:
574
- error_msg = results[0].get('error', 'No ID returned') if results else 'No ID returned'
575
- click.secho(f"Entry creation failed: {error_msg}", fg="red")
576
-
577
- except Exception as e:
578
- click.secho(f"Error: {e}", fg="red")
579
-
580
- @entry.command("upload-digital-object")
581
- @click.option('--entry-code', required=True, help='Entry code to attach the digital object to')
582
- @click.option('--type', 'digital_object_type', required=True, help='Digital object type name (e.g., Dataset, File, Image)')
583
- @click.option('--description', 'file_description', default='', help='Description for the uploaded file')
584
- @click.argument('file_path', required=True)
585
- @pass_das_context
586
- def upload_digital_object(das_ctx, entry_code, digital_object_type, file_description, file_path):
587
- """Upload a file as a digital object and link it to an entry.
588
-
589
- Examples:
590
-
591
- \b
592
- # Upload a dataset file and link to an entry
593
- das entry upload-digital-object --entry-code ENT001 --type Dataset --description "CTD raw" c:\\data\\ctd.zip
594
- """
595
- try:
596
- # Ensure services are initialized
597
- das_ctx.get_client()
598
-
599
- # Perform upload and link
600
- digital_object_id = das_ctx.digital_objects_manager.upload_digital_object(
601
- entry_code=entry_code,
602
- file_description=file_description,
603
- digital_object_type=digital_object_type,
604
- file_path=file_path,
605
- )
606
-
607
- if digital_object_id:
608
- click.secho("✓ Digital object uploaded and linked successfully!", fg="green")
609
- click.echo(f"Digital Object ID: {digital_object_id}")
610
- else:
611
- click.secho("Upload completed but no ID was returned.", fg="yellow")
612
- except Exception as e:
613
- click.secho(f"Error: {e}", fg="red")
614
-
615
- @entry.command("get")
616
- @click.option('--code', default=None, help='Entry code')
617
- @click.option('--id', type=int, default=None, help='Entry ID')
618
- @pass_das_context
619
- def get_entry(das_ctx, code=None, id=None):
620
- """Get entry by either its code or ID"""
621
-
622
- if not code and not id:
623
- raise click.UsageError("Please provide either an entry code or ID")
624
-
625
- try:
626
- # Ensure client and entry_manager are initialized
627
- das_ctx.get_client()
628
- # Pass client as a named parameter to avoid conflicts with 'id' parameter
629
- entry = das_ctx.entry_manager.get(code=code, id=id)
630
- if entry:
631
- click.secho("Entry Details:", fg="green", bold=True)
632
- click.echo("─" * 40)
633
- for key, value in entry.items():
634
- if key == 'Digital Object(s)' and isinstance(value, list):
635
- continue
636
- elif isinstance(value, list):
637
- click.echo(f"{key}:")
638
- for item in value:
639
- click.echo(f" - {item}")
640
- else:
641
- click.echo(f"{key}: {value or ''}")
642
-
643
- # if entry contains key: Digital Object(s) and its a list, print each object details
644
- # with indentation for better readability
645
- if 'Digital Object(s)' in entry and isinstance(entry['Digital Object(s)'], list):
646
- click.echo()
647
- click.echo("─" * 40)
648
- click.echo("Digital Object(s):")
649
- for obj in entry['Digital Object(s)']:
650
- click.echo(f" - ID: {obj.get('Id')}")
651
- click.echo(f" Name: {obj.get('Name')}")
652
- click.echo(f" Type: {obj.get('Type')}")
653
- click.echo(f" Links: {obj.get('Links')}")
654
- click.echo()
655
- click.echo("─" * 40)
656
- else:
657
- click.secho("Entry not found.", fg="yellow")
658
- click.echo("Please check the entry code or ID.")
659
-
660
- except Exception as e:
661
- click.secho(f"Error: {e}", fg="red")
662
-
663
- @entry.command("chown")
664
- @click.option('--user', 'user_name', required=True, help='New owner username')
665
- @click.option('--code', '-c', multiple=True, required=True, help='Entry code to transfer. Can be used multiple times.')
666
- @pass_das_context
667
- def chown_entries(das_ctx, user_name, code):
668
- """Change ownership of one or more entries by their codes.
669
-
670
- Example:
671
-
672
- \b
673
- das entry chown --user alice --code ENT001 --code ENT002
674
- """
675
- try:
676
- # Ensure services are initialized
677
- das_ctx.get_client()
678
-
679
- entry_codes = list(code)
680
- if not entry_codes:
681
- raise click.UsageError("Please provide at least one --code")
682
-
683
- result = das_ctx.entry_manager.chown(user_name=user_name, entry_code_list=entry_codes)
684
-
685
- # If API returns a plain success or list, just report success
686
- click.secho("✓ Ownership updated successfully!", fg="green")
687
- if isinstance(result, dict):
688
- # Show minimal feedback if available
689
- updated = result.get('updated') or result.get('result') or result
690
- if updated:
691
- click.echo(json.dumps(updated, indent=2))
692
- except Exception as e:
693
- click.secho(f"Error: {e}", fg="red")
694
-
695
- # Attribute commands group
696
- @cli.group()
697
- def attribute():
698
- """Commands for working with attributes"""
699
- pass
700
-
701
- # Cache commands group
702
- @cli.group()
703
- def cache():
704
- """Commands for working with cache"""
705
- pass
706
-
707
- # Config commands group
708
- @cli.group()
709
- def config():
710
- """Commands for configuring the CLI"""
711
- pass
712
-
713
- @cache.command("clear-all")
714
- @pass_das_context
715
- def clear_all_cache(das_ctx):
716
- """Clear all cache entries"""
717
- client = das_ctx.get_client()
718
- try:
719
- result = client.cache.clear_all()
720
- if result.get('success'):
721
- click.secho("✓ All cache cleared!", fg="green")
722
- else:
723
- click.secho("Failed to clear cache.", fg="red")
724
- except Exception as e:
725
- click.secho(f"Error: {e}", fg="red")
726
-
727
- @cache.command("list")
728
- @pass_das_context
729
- def list_cache(das_ctx):
730
- """List all cache entries"""
731
- client = das_ctx.get_client()
732
- try:
733
- caches = client.cache.get_all()
734
- if caches:
735
- click.secho("Cache Entries:", fg="green", bold=True)
736
- click.echo("─" * 40)
737
- # Sort cache items by name before displaying
738
- sorted_caches = sorted(caches.get('items', []), key=lambda x: x.get('name', '').lower())
739
- for cache in sorted_caches:
740
- click.echo(f"Name: {cache.get('name')}")
741
- click.echo("─" * 40)
742
- else:
743
- click.secho("No cache entries found.", fg="yellow")
744
- except Exception as e:
745
- click.secho(f"Error: {e}", fg="red")
746
-
747
- @cache.command("clear")
748
- @click.argument('name', required=True)
749
- @pass_das_context
750
- def clear_cache(das_ctx, name):
751
- """Clear a specific cache by name"""
752
- client = das_ctx.get_client()
753
- try:
754
- result = client.cache.clear_cache(name)
755
- if result.get('success'):
756
- click.secho(f"✓ Cache '{name}' cleared!", fg="green")
757
- else:
758
- click.secho(f"Failed to clear cache '{name}'.", fg="red")
759
- except Exception as e:
760
- click.secho(f"Error: {e}", fg="red")
761
-
762
- @attribute.command("get")
763
- @click.option('--id', type=int, default=None, help='Attribute ID')
764
- @click.option('--name', default=None, help='Attribute name')
765
- @click.option('--alias', default=None, help='Attribute alias')
766
- @click.option('--table-name', default=None, help='Table name')
767
- @pass_das_context
768
- def get_attribute(das_ctx, id, name, alias, table_name):
769
- """Get attribute by ID, name, alias, or table name"""
770
- if not any([id, name, alias, table_name]):
771
- raise click.UsageError("Please provide at least one search parameter")
772
-
773
- client = das_ctx.get_client()
774
- try:
775
- result = client.attributes.get_attribute(id=id, name=name, alias=alias, table_name=table_name)
776
- if result.get('success') and result.get('result', {}).get('items'):
777
- attributes = result['result']['items']
778
- for attr in attributes:
779
- click.secho("\nAttribute Details:", fg="green", bold=True)
780
- click.echo("─" * 40)
781
- click.echo(f"ID: {attr.get('id')}")
782
- click.echo(f"Name: {attr.get('name')}")
783
- click.echo(f"Description: {attr.get('description')}")
784
- click.echo(f"Alias: {attr.get('alias')}")
785
- click.echo(f"Table Name: {attr.get('tableName')}")
786
- click.echo(f"Menu Name: {attr.get('menuName')}")
787
- click.echo(f"Context: {attr.get('contextName')}")
788
- click.echo(f"Indexable: {attr.get('isIndexable')}")
789
- if attr.get('indexName'):
790
- click.echo(f"Index Name: {attr.get('indexName')}")
791
- click.echo("─" * 40)
792
- else:
793
- click.secho("No attributes found.", fg="yellow")
794
- except Exception as e:
795
- click.secho(f"Error: {e}", fg="red")
796
-
797
-
798
- @attribute.command("get-name")
799
- @click.argument('id', type=int, required=True)
800
- @pass_das_context
801
- def get_attribute_name(das_ctx, id):
802
- """Get attribute name by ID"""
803
- client = das_ctx.get_client()
804
- try:
805
- name = client.attributes.get_name(id)
806
- click.echo(name)
807
- except Exception as e:
808
- click.secho(f"Error: {e}", fg="red")
809
-
810
- @attribute.command("get-id")
811
- @click.argument('name', required=True)
812
- @pass_das_context
813
- def get_attribute_id(das_ctx, name):
814
- """Get attribute ID by name"""
815
- client = das_ctx.get_client()
816
- try:
817
- attr_id = client.attributes.get_id(name)
818
- click.echo(attr_id)
819
- except Exception as e:
820
- click.secho(f"Error: {e}", fg="red")
821
-
822
- # SSL verification commands
823
- @config.command("ssl-verify")
824
- @click.argument('enabled', type=click.Choice(['true', 'false']), required=True)
825
- def set_ssl_verify(enabled):
826
- """Set SSL certificate verification (true/false)"""
827
- verify = enabled.lower() == 'true'
828
- save_verify_ssl(verify)
829
- status = "enabled" if verify else "disabled"
830
- click.echo(f"SSL certificate verification {status}")
831
-
832
- @config.command("ssl-status")
833
- def get_ssl_status():
834
- """Show current SSL certificate verification status"""
835
- status = "enabled" if VERIFY_SSL else "disabled"
836
- click.echo(f"SSL certificate verification is currently {status}")
837
-
838
- @config.command("reset")
839
- @click.option('--force', is_flag=True, help='Skip confirmation prompt')
840
- def reset_config(force):
841
- """Clear all configuration files (token, URL, and authentication info)"""
842
- if not force:
843
- if not click.confirm("This will remove all saved credentials and configuration. Are you sure?"):
844
- click.echo("Operation cancelled.")
845
- return
846
-
847
- from das.common.config import clear_token, _config_dir
848
- import shutil
849
-
850
- # Clear token (handles both keyring and file-based storage)
851
- clear_token()
852
-
853
- # Get the config directory path
854
- config_dir = _config_dir()
855
-
856
- # Check if the directory exists
857
- if config_dir.exists():
858
- # Remove all files in the config directory
859
- for file_path in config_dir.glob("*"):
860
- if file_path.is_file():
861
- try:
862
- file_path.unlink()
863
- click.echo(f"Removed: {file_path.name}")
864
- except Exception as e:
865
- click.secho(f"Failed to remove {file_path.name}: {e}", fg="red")
866
-
867
- click.secho("✓ All configuration files and credentials have been removed.", fg="green")
868
-
869
- # Download commands group
870
- @cli.group()
871
- def download():
872
- """Commands for working with downloads"""
873
- pass
874
-
875
- @download.command("request")
876
- @click.option('--name', help='Name for the download request (defaults to timestamp if not provided)')
877
- @click.option('--entry', '-e', multiple=True, required=True, help='Entry code to download files from. Can be used multiple times.')
878
- @click.option('--file', '-f', multiple=True, help='File codes to download. If not specified, all files will be downloaded.')
879
- @click.option('--from-file', help='Load download request from a JSON file')
880
- @click.option('--format', 'output_format', default='table', type=click.Choice(['table', 'json']), help='Output format (table or json)')
881
- @pass_das_context
882
- def create_download_request(das_ctx, name, entry, file, from_file, output_format):
883
- """
884
- Create a new download request for specified entries and files
885
-
886
- Examples:
887
-
888
- \b
889
- # Download all files from an entry
890
- das download request --entry ENT001
891
-
892
- \b
893
- # Download specific files from an entry
894
- das download request --entry ENT001 --file FILE001 --file FILE002
895
-
896
- \b
897
- # Download from multiple entries
898
- das download request --entry ENT001 --entry ENT002 --name "My Download"
899
-
900
- \b
901
- # Download specific files from multiple entries
902
- das download request --entry ENT001 --file FILE001 --entry ENT002 --file FILE003
903
-
904
- \b
905
- # Download using a JSON file specification
906
- das download request --from-file request.json
907
- """
908
- try:
909
- request_data = {}
910
-
911
- # Handle name parameter
912
- if name:
913
- request_data['name'] = name
914
-
915
- # Handle from-file parameter
916
- if from_file:
917
- click.echo(f"Loading download request from file: {from_file}")
918
- try:
919
- with open(from_file, 'r') as f:
920
- file_data = json.load(f)
921
- request_data.update(file_data)
922
- except Exception as e:
923
- raise click.UsageError(f"Failed to load file: {e}")
924
- # Handle entry and file parameters
925
- else:
926
- # Group files by entry
927
- current_entry = None
928
- entry_files = {}
929
-
930
- # If we have entries and files, we need to pair them correctly
931
- if entry and file:
932
- for arg in sys.argv:
933
- if arg == '--entry' or arg == '-e':
934
- # Next arg will be an entry code
935
- current_entry = None
936
- elif arg == '--file' or arg == '-f':
937
- # Next arg will be a file code
938
- pass
939
- elif current_entry is None and arg in entry:
940
- # This is an entry code
941
- current_entry = arg
942
- if current_entry not in entry_files:
943
- entry_files[current_entry] = []
944
- elif current_entry is not None and arg in file:
945
- # This is a file code for the current entry
946
- entry_files[current_entry].append(arg)
947
- else:
948
- # If we have entries but no files, download all files for each entry
949
- for e in entry:
950
- entry_files[e] = []
951
-
952
- # Update request_data with the entry files
953
- for e, files in entry_files.items():
954
- request_data[e] = files
955
-
956
- if not request_data or all(key == 'name' for key in request_data.keys()):
957
- raise click.UsageError("No download request data provided")
958
-
959
- # Execute the download request
960
- # Make sure client and download_manager are initialized
961
- das_ctx.get_client()
962
- result = das_ctx.download_manager.create_download_request(request_data)
963
-
964
- # Check if result contains errors
965
- if isinstance(result, dict) and 'errors' in result:
966
- click.secho("Download request failed with errors:", fg="red")
967
- for error in result['errors']:
968
- click.secho(f"- {error}", fg="red")
969
- return
970
-
971
- # Handle the result based on its type
972
- if output_format == 'json':
973
- # If result is a string (just the ID), wrap it in a dict for consistent JSON output
974
- if isinstance(result, str):
975
- click.echo(json.dumps({"id": result}, indent=2))
976
- else:
977
- click.echo(json.dumps(result, indent=2))
978
- else:
979
- click.secho("✓ Download request created successfully!", fg="green")
980
-
981
- # If result is a string, it's just the request ID
982
- if isinstance(result, str):
983
- request_id = result
984
- click.echo(f"Request ID: {request_id}")
985
-
986
- # Show download instructions
987
- click.echo("\nUse the following command to check the status of your download:")
988
- click.secho(f" das download status {request_id}", fg="cyan")
989
- else:
990
- # Handle the case where result is a dictionary with more information
991
- request_id = result.get('id', 'Unknown')
992
- click.echo(f"Request ID: {request_id}")
993
- click.echo(f"Status: {result.get('status', 'Pending')}")
994
-
995
- # Display files in the request if available
996
- if result.get('items'):
997
- click.secho("\nFiles in this download request:", fg="blue")
998
-
999
- headers = ["#", "File Name", "Entry", "Size", "Status"]
1000
- table_data = []
1001
-
1002
- for i, item in enumerate(result.get('items', []), 1):
1003
- file_info = item.get('fileInfo', {})
1004
- table_data.append([
1005
- i,
1006
- file_info.get('name', 'Unknown'),
1007
- file_info.get('entryCode', 'Unknown'),
1008
- file_info.get('size', 'Unknown'),
1009
- item.get('status', 'Pending')
1010
- ])
1011
-
1012
- click.echo(format_table(table_data, headers))
1013
-
1014
- # Show download instructions
1015
- click.echo("\nUse the following command to check the status of your download:")
1016
- click.secho(f" das download status {request_id}", fg="cyan")
1017
-
1018
- except Exception as e:
1019
- click.secho(f"Error: {e}", fg="red")
1020
-
1021
- @download.command("files")
1022
- @click.argument('request_id', required=True)
1023
- @click.option('--out', 'output_path', required=False, default='.', help='Output file path or directory (defaults to current directory)')
1024
- @click.option('--force', is_flag=True, help='Overwrite existing file if present')
1025
- @pass_das_context
1026
- def download_files(das_ctx, request_id, output_path, force):
1027
- """
1028
- Download the completed bundle for a download request and save it to disk.
1029
-
1030
- Examples:
1031
-
1032
- \b
1033
- # Save into current directory with server-provided filename
1034
- das download files 6b0e68e6-00cd-43a7-9c51-d56c9c091123
1035
-
1036
- \b
1037
- # Save to a specific folder
1038
- das download files 6b0e68e6-00cd-43a7-9c51-d56c9c091123 --out C:\\Downloads
1039
-
1040
- \b
1041
- # Save to an explicit filename, overwriting if exists
1042
- das download files 6b0e68e6-00cd-43a7-9c51-d56c9c091123 --out C:\\Downloads\\bundle.zip --force
1043
- """
1044
- try:
1045
- das_ctx.get_client()
1046
- saved_path = das_ctx.download_manager.save_download(request_id=request_id, output_path=output_path, overwrite=force)
1047
- click.secho(f"✓ Download saved to: {saved_path}", fg="green")
1048
- except FileExistsError as e:
1049
- click.secho(str(e), fg="yellow")
1050
- except Exception as e:
1051
- click.secho(f"Error: {e}", fg="red")
1052
-
1053
- @download.command("delete-request")
1054
- @click.argument('request_id', required=True)
1055
- @pass_das_context
1056
- def delete_download_request(das_ctx, request_id):
1057
- """
1058
- Delete a download request by its ID
1059
-
1060
- Example:
1061
-
1062
- \b
1063
- # Delete a download request
1064
- das download delete-request 6b0e68e6-00cd-43a7-9c51-d56c9c091123
1065
- """
1066
- try:
1067
- # Ensure client and download_manager are initialized
1068
- das_ctx.get_client()
1069
-
1070
- # Call the delete_download_request method
1071
- result = das_ctx.download_manager.delete_download_request(request_id)
1072
-
1073
- # Display success message
1074
- click.secho(f"✓ Download request '{request_id}' deleted successfully!", fg="green")
1075
-
1076
- except Exception as e:
1077
- click.secho(f"Error: {e}", fg="red")
1078
-
1079
-
1080
- @download.command("my-requests")
1081
- @click.option('--format', 'output_format', default='table', type=click.Choice(['table', 'json']), help='Output format (table or json)')
1082
- @pass_das_context
1083
- def list_my_download_requests(das_ctx, output_format):
1084
- """List your download requests in a user-friendly format"""
1085
- try:
1086
- # Ensure services are initialized
1087
- das_ctx.get_client()
1088
- result = das_ctx.download_manager.get_my_requests()
1089
-
1090
- # Normalize result shape
1091
- total_count = 0
1092
- items = []
1093
- if isinstance(result, dict):
1094
- total_count = result.get('totalCount', 0)
1095
- items = result.get('items', [])
1096
- elif isinstance(result, list):
1097
- items = result
1098
- total_count = len(items)
1099
-
1100
- if output_format == 'json':
1101
- payload = { 'totalCount': total_count, 'items': items }
1102
- click.echo(json.dumps(payload, indent=2))
1103
- return
1104
-
1105
- if not items:
1106
- click.secho("No download requests found.", fg="yellow")
1107
- return
1108
-
1109
- # Build a compact table
1110
- headers = ["#", "ID", "Requester", "Created", "Status", "Files"]
1111
- table_data = []
1112
-
1113
- from das.common.enums import DownloadRequestStatus
1114
-
1115
- def map_status(code):
1116
- try:
1117
- return DownloadRequestStatus(code).name.replace('_', ' ').title()
1118
- except Exception:
1119
- return str(code)
1120
-
1121
- def fmt_dt(dt_str):
1122
- if not isinstance(dt_str, str) or 'T' not in dt_str:
1123
- return dt_str or ''
1124
- date_part = dt_str.split('T')[0]
1125
- time_part = dt_str.split('T')[1].split('.')[0]
1126
- return f"{date_part} {time_part}"
1127
-
1128
- for i, req in enumerate(items, 1):
1129
- req_id = req.get('id', '')
1130
- requester = req.get('requester', '')
1131
- created = fmt_dt(req.get('createdOn'))
1132
- status = map_status(req.get('status'))
1133
- files = req.get('files') or []
1134
- file_count = len(files)
1135
- table_data.append([
1136
- i,
1137
- req_id[:8],
1138
- requester.split(' - ')[0] if isinstance(requester, str) else requester,
1139
- created,
1140
- status,
1141
- file_count
1142
- ])
1143
-
1144
- click.secho(f"\nYour download requests ({total_count})", fg="blue")
1145
- click.echo(format_table(table_data, headers))
1146
-
1147
- # Show a brief file breakdown below the table
1148
- click.secho("\nDetails:", fg="blue")
1149
- for i, req in enumerate(items, 1):
1150
- files = req.get('files') or []
1151
- if not files:
1152
- continue
1153
- click.echo(f"{i}. {req.get('id', '')}")
1154
- for f in files[:5]:
1155
- # Map item status if present
1156
- status_code = f.get('status')
1157
- status_label = None
1158
- try:
1159
- from das.common.enums import DownloadRequestItemStatus
1160
- status_label = DownloadRequestItemStatus(status_code).name.replace('_', ' ').title()
1161
- except Exception:
1162
- status_label = str(status_code)
1163
- click.echo(f" - {f.get('fileName', f.get('needle', ''))} [{status_label}] ({f.get('digitalObjectType', '')})")
1164
- if len(files) > 5:
1165
- click.echo(f" ... and {len(files) - 5} more")
1166
-
1167
- except Exception as e:
1168
- click.secho(f"Error: {e}", fg="red")
1169
-
1170
- # DAS AI commands group
1171
- @cli.group()
1172
- def ai():
1173
- """Commands for working with DAS AI"""
1174
- pass
1175
-
1176
- @ai.command("enable")
1177
- @pass_das_context
1178
- def enable_das_ai(das_ctx):
1179
- """Enable DAS AI interactive mode"""
1180
- try:
1181
- # Ensure OpenAI API key is configured
1182
- api_key = os.getenv("OPENAI_API_KEY") or load_openai_api_key()
1183
- if not api_key:
1184
- click.secho("No OpenAI API key found.", fg="yellow")
1185
- click.echo("You can set it via environment variable OPENAI_API_KEY, or save it now.")
1186
- key = click.prompt("Enter your OpenAI API key", hide_input=True)
1187
- if not key:
1188
- raise click.UsageError("OpenAI API key is required to enable DAS AI.")
1189
- save_openai_api_key(key)
1190
- click.secho("✓ OpenAI API key saved securely.", fg="green")
1191
-
1192
- # Get DAS AI instance
1193
- das_ai = das_ctx.get_das_ai()
1194
-
1195
- click.secho("🤖 DAS AI is now enabled!", fg="green", bold=True)
1196
- click.echo("Starting interactive AI session...")
1197
- click.echo("Type 'exit' to quit the AI session.")
1198
- click.echo("=" * 50)
1199
-
1200
- # Run the AI main loop
1201
- import asyncio
1202
- asyncio.run(das_ai.main())
1203
-
1204
- except Exception as e:
1205
- click.secho(f"Error enabling DAS AI: {e}", fg="red")
1206
- click.echo("Make sure you have set your OPENAI_API_KEY environment variable.")
1207
-
1208
- def _ai_clear_impl(force: bool):
1209
- if not force:
1210
- if not click.confirm("This will remove your saved OpenAI API key and authentication token. Continue?"):
1211
- click.echo("Operation cancelled.")
1212
- return
1213
- try:
1214
- clear_openai_api_key()
1215
- clear_token()
1216
- cfg_dir = _config_dir()
1217
- removed_any = False
1218
- for fname in ["openai_key.json", "token.json"]:
1219
- p = cfg_dir / fname
1220
- if p.exists() and p.is_file():
1221
- try:
1222
- p.unlink()
1223
- removed_any = True
1224
- except Exception:
1225
- pass
1226
- click.secho("✓ DAS AI credentials cleared.", fg="green")
1227
- if removed_any:
1228
- click.echo("Local credential files removed.")
1229
- except Exception as e:
1230
- click.secho(f"Error clearing credentials: {e}", fg="red")
1231
-
1232
- @ai.command("clear")
1233
- @click.option('--force', is_flag=True, help='Skip confirmation prompt')
1234
- def ai_clear(force):
1235
- """Clear DAS AI credentials (OpenAI key) and auth token."""
1236
- _ai_clear_impl(force)
1237
-
1238
- @ai.command("logout")
1239
- @click.option('--force', is_flag=True, help='Skip confirmation prompt')
1240
- def ai_logout(force):
1241
- """Alias for 'das ai clear'"""
1242
- _ai_clear_impl(force)
1243
-
1244
- if __name__ == "__main__":
1245
- cli()
1
+ import sys
2
+ import click
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from das.common.config import (
7
+ save_api_url, load_api_url, DEFAULT_BASE_URL,
8
+ save_verify_ssl, load_verify_ssl, VERIFY_SSL,
9
+ load_openai_api_key, save_openai_api_key, clear_openai_api_key,
10
+ clear_token, _config_dir
11
+ )
12
+
13
+ from das.app import Das
14
+ from das.managers.download_manager import DownloadManager
15
+ from das.managers.entries_manager import EntryManager
16
+ from das.managers.search_manager import SearchManager
17
+ from das.managers.digital_objects_manager import DigitalObjectsManager
18
+ from das.common.file_utils import load_file_based_on_extension, parse_data_string
19
+ from das.ai.plugins.dasai import DasAI
20
+
21
+ # Simple table formatting function to avoid external dependencies
22
+ def format_table(data, headers):
23
+ """Format data into a text-based table without external dependencies"""
24
+ if not data:
25
+ return "No data to display"
26
+
27
+ # Calculate column widths
28
+ col_widths = [max(len(str(h)), max([len(str(row[i])) for row in data] or [0])) for i, h in enumerate(headers)]
29
+
30
+ # Create the separator line
31
+ separator = '+' + '+'.join('-' * (w + 2) for w in col_widths) + '+'
32
+
33
+ # Create the header row
34
+ header_row = '|' + '|'.join(' ' + str(h).ljust(w) + ' ' for h, w in zip(headers, col_widths)) + '|'
35
+
36
+ # Create the data rows
37
+ data_rows = []
38
+ for row in data:
39
+ data_rows.append('|' + '|'.join(' ' + str(cell).ljust(w) + ' ' for cell, w in zip(row, col_widths)) + '|')
40
+
41
+ # Assemble the table
42
+ table = [separator, header_row, separator]
43
+ table.extend(data_rows)
44
+ table.append(separator)
45
+
46
+ return '\n'.join(table)
47
+
48
+ class DasCLI:
49
+ def __init__(self):
50
+ self.client = None
51
+ self.api_url = None
52
+ self.entry_manager = None
53
+ self.search_manager = None
54
+ self.download_manager = None
55
+ self.digital_objects_manager = None
56
+ self.das_ai = None
57
+
58
+ def get_client(self):
59
+ """Get DAS client instance, using saved API URL"""
60
+ if not self.api_url:
61
+ self.api_url = load_api_url() or DEFAULT_BASE_URL
62
+ if not self.api_url:
63
+ raise click.UsageError(
64
+ "No API URL configured. Please login first with 'das login --api-url <URL>'"
65
+ )
66
+
67
+ self.entry_manager = EntryManager()
68
+ self.search_manager = SearchManager()
69
+ self.download_manager = DownloadManager()
70
+ self.digital_objects_manager = DigitalObjectsManager()
71
+ # Set SSL verification based on saved config
72
+ if not self.client:
73
+ self.client = Das(self.api_url)
74
+
75
+ return self.client
76
+
77
+ def get_das_ai(self):
78
+ """Get DAS AI instance"""
79
+ if not self.das_ai:
80
+ self.das_ai = DasAI()
81
+ return self.das_ai
82
+
83
+ pass_das_context = click.make_pass_decorator(DasCLI, ensure=True)
84
+
85
+ @click.group()
86
+ @click.version_option(package_name="das-cli")
87
+ @click.pass_context
88
+ def cli(ctx):
89
+ """DAS Python CLI - Data Archive System client tool"""
90
+ ctx.obj = DasCLI()
91
+
92
+ @cli.command()
93
+ @click.option('--api-url', required=True, help='API base URL')
94
+ @click.option('--username', required=True, prompt=True, help='Username')
95
+ @click.option('--password', required=True, prompt=True, hide_input=True, help='Password')
96
+ @pass_das_context
97
+ def login(das_ctx, api_url, username, password):
98
+ """Login and store authentication token"""
99
+ # Save API URL for future use
100
+ save_api_url(api_url)
101
+ das_ctx.api_url = api_url
102
+
103
+ # Authenticate
104
+ client = das_ctx.get_client()
105
+ token = client.authenticate(username, password)
106
+ if not token:
107
+ click.secho("❌ Authentication failed. Please check your credentials.", fg="red")
108
+ return
109
+ else:
110
+ click.secho("✓ Authentication successful!", fg="green")
111
+
112
+ # Search commands group
113
+ @cli.group()
114
+ def search():
115
+ """Commands for searching entries"""
116
+ pass
117
+
118
+ @search.command("help")
119
+ def search_help():
120
+ """Show help about search query syntax and formats"""
121
+ click.secho("\nSearch Query Syntax Help", fg="green", bold=True)
122
+ click.echo("=" * 50)
123
+
124
+ click.secho("\nBasic Search:", fg="blue", bold=True)
125
+ click.echo(" das search entries --attribute <AttributeName> --query '<SearchQuery>'")
126
+ click.echo(" Example: das search entries --attribute Cores --query 'name(*64*)'")
127
+
128
+ click.secho("\nOutput Formats:", fg="blue", bold=True)
129
+ click.echo(" --format table Format results in a nice table (default)")
130
+ click.echo(" --format json Return results as JSON")
131
+ click.echo(" --format compact Simple one-line-per-result format")
132
+ click.echo(" --raw Show raw API response")
133
+
134
+ click.secho("\nQuery Syntax Examples:", fg="blue", bold=True)
135
+ click.echo(" 'name(*pattern*)' Search for pattern in name")
136
+ click.echo(" 'name(*pattern*);code(*ABC*)' Multiple conditions (AND)")
137
+ click.echo(" 'Create at(>2023-01-01)' Date comparison")
138
+
139
+ click.secho("\nGet Detailed Information:", fg="blue", bold=True)
140
+ click.echo(" das search entry <ID> Get detailed info for a single entry")
141
+ click.echo(" Example: das search entry 6b0e68e6-00cd-43a7-9c51-d56c9c091123")
142
+
143
+ click.secho("\nPagination:", fg="blue", bold=True)
144
+ click.echo(" --page <num> Show specific page of results")
145
+ click.echo(" --max-results <n> Number of results per page (default: 10)")
146
+
147
+ click.secho("\nSorting:", fg="blue", bold=True)
148
+ click.echo(" --sort-by <field> Field to sort by (default: Name)")
149
+ click.echo(" --sort-order <order> Sort order: 'asc' or 'desc' (default: asc)")
150
+ click.echo("\n")
151
+
152
+ @search.command("entries")
153
+ @click.option('--attribute', required=True, help='Attribute name to search in')
154
+ @click.option('--query', required=True, help='Search query string')
155
+ @click.option('--max-results', default=10, help='Maximum number of results to return')
156
+ @click.option('--page', default=1, help='Page number for paginated results')
157
+ @click.option('--sort-by', default='Name', help='Field to sort by (default: Name)')
158
+ @click.option('--sort-order', default='asc', type=click.Choice(['asc', 'desc']), help='Sort order (asc or desc)')
159
+ @click.option('--format', 'output_format', default='table', type=click.Choice(['table', 'json', 'compact']), help='Output format (table, json, or compact)')
160
+ @click.option('--raw', is_flag=True, help='Show raw response from API')
161
+ @pass_das_context
162
+ def search_entries(das_ctx, attribute, query, max_results, page, sort_by, sort_order, output_format, raw):
163
+ """Search entries based on attribute and query"""
164
+ try:
165
+ # Ensure client and search_manager are initialized
166
+ das_ctx.get_client()
167
+
168
+ results = das_ctx.search_manager.search_entries(
169
+ attribute=attribute,
170
+ query=query,
171
+ max_results=max_results,
172
+ page=page,
173
+ sort_by=sort_by,
174
+ sort_order=sort_order
175
+ )
176
+
177
+ if raw:
178
+ click.echo(results)
179
+ return
180
+
181
+ total_count = results.get('totalCount', 0)
182
+ items = results.get('items', [])
183
+
184
+ # Display search summary
185
+ click.secho(f"\nFound {total_count} results for query: '{query}' in attribute: '{attribute}'", fg="blue")
186
+ click.secho(f"Showing results {(page-1)*max_results + 1}-{min(page*max_results, total_count)} of {total_count}\n", fg="blue")
187
+
188
+ if not items:
189
+ click.secho("No results found.", fg="yellow")
190
+ return
191
+
192
+ if output_format == 'json':
193
+ click.echo(json.dumps(results, indent=2))
194
+ elif output_format == 'compact':
195
+ for i, item in enumerate(items, 1):
196
+ entry = item.get('entry', {})
197
+ code = entry.get('code', 'N/A')
198
+ name = entry.get('displayname', 'Unnamed')
199
+ description = entry.get('description', 'No description')
200
+ click.echo(f"{i}. {name} (Code: {code}) - {description}")
201
+ else: # table format
202
+ headers = ["#", "Code", "Name", "Description", "Owner"]
203
+ table_data = []
204
+
205
+ for i, item in enumerate(items, 1):
206
+ entry = item.get('entry', {})
207
+ table_data.append([
208
+ i,
209
+ entry.get('code', 'N/A'),
210
+ entry.get('displayname', 'Unnamed'),
211
+ entry.get('description', 'No description')[:50] + ('...' if entry.get('description', '') and len(entry.get('description', '')) > 50 else ''),
212
+ entry.get('owner', 'Unknown')
213
+ ])
214
+
215
+ click.echo(format_table(table_data, headers))
216
+
217
+ except Exception as e:
218
+ click.secho(f"Error: {e}", fg="red")
219
+
220
+ # Hangfire commands group
221
+ @search.command("entry")
222
+ @click.argument('entry_id', required=True)
223
+ @click.option('--format', 'output_format', default='table', type=click.Choice(['table', 'json']), help='Output format (table or json)')
224
+ @pass_das_context
225
+ def get_entry(das_ctx, entry_id, output_format):
226
+ """Get detailed information about a single entry by ID"""
227
+ try:
228
+ # Ensure client and entry_manager are initialized
229
+ das_ctx.get_client()
230
+ # Reusing the entry manager to get entry details by ID
231
+ entry = das_ctx.entry_manager.get_entry(entry_id)
232
+
233
+ if output_format == 'json':
234
+ click.echo(json.dumps(entry, indent=2))
235
+ else:
236
+ click.secho(f"\nEntry Details (ID: {entry_id})", fg="blue", bold=True)
237
+ click.echo("=" * 50)
238
+
239
+ if not entry or not isinstance(entry, dict):
240
+ click.secho("No entry found with the specified ID.", fg="yellow")
241
+ return
242
+
243
+ # Format display fields in a readable way
244
+ formatted_data = []
245
+ for key, value in sorted(entry.items()):
246
+ # Skip empty or None values
247
+ if value is None or value == '':
248
+ continue
249
+
250
+ # Format date fields
251
+ if isinstance(value, str) and key.lower().endswith(('time', 'date')) and 'T' in value:
252
+ try:
253
+ date_part = value.split('T')[0]
254
+ time_part = value.split('T')[1].split('.')[0]
255
+ value = f"{date_part} {time_part}"
256
+ except:
257
+ pass # Keep original if formatting fails
258
+
259
+ # Add to formatted data
260
+ formatted_data.append((key, value))
261
+
262
+ # Display as key-value pairs
263
+ for key, value in formatted_data:
264
+ click.echo(f"{key.ljust(25)}: {value}")
265
+
266
+ except Exception as e:
267
+ click.secho(f"Error: {e}", fg="red")
268
+
269
+ @cli.group()
270
+ def hangfire():
271
+ """Commands for working with Hangfire tasks"""
272
+ pass
273
+
274
+ @hangfire.command("sync-doi")
275
+ @click.argument('id', required=True)
276
+ @pass_das_context
277
+ def sync_doi(das_ctx, id):
278
+ """Trigger a DOI synchronization task by ID"""
279
+ client = das_ctx.get_client()
280
+ try:
281
+ client.hangfire.sync_doi(id)
282
+ click.secho(f"✓ DOI synchronization task triggered!", fg="green")
283
+ except Exception as e:
284
+ click.secho(f"Error: {e}", fg="red")
285
+
286
+ # Entries commands group
287
+ @cli.group()
288
+ def entry():
289
+ """Commands for working with entries"""
290
+ pass
291
+
292
+ @entry.command("update")
293
+ @click.option('--id', default=None, help='Entry ID to update (only used with --data for single entry)')
294
+ @click.option('--code', default=None, help='Entry code to update (only used with --data for single entry)')
295
+ @click.argument('file_path', required=False)
296
+ @click.option('--data', help='Data string in format { "key1": "value1", "key2": "value2", ... } or a list of such objects')
297
+ @pass_das_context
298
+ def update_entry(das_ctx, id, code, file_path, data):
299
+ """Update entries from file or data string by ID or code.
300
+
301
+ For single updates, provide either --id or --code (or Id/Code in the data).
302
+ For bulk updates, each entry must contain a Code or Id field.
303
+ Files can contain multiple entries (rows in CSV/XLS or list of objects in JSON).
304
+
305
+ Examples:
306
+
307
+ \b
308
+ # Update by code from data string
309
+ das entry update --code ENT001 --data "{ 'Grant Public Access': 'Yes' }"
310
+
311
+ \b
312
+ # Update by ID from data string
313
+ das entry update --id 9b826efc-36f3-471c-ad18-9c1e00abe4fa --data "{ 'Name': 'Updated' }"
314
+
315
+ \b
316
+ # Update entries from JSON file (each object needs Code or Id)
317
+ das entry update c:\\data\\entries.json
318
+
319
+ \b
320
+ # Update entries from CSV file
321
+ das entry update c:\\data\\entries.csv
322
+
323
+ \b
324
+ # Update multiple entries from data string
325
+ das entry update --data "[{ 'Code': 'ENT001', ... }, { 'Code': 'ENT002', ... }]"
326
+ """
327
+ try:
328
+ # Ensure client and entry_manager are initialized
329
+ das_ctx.get_client()
330
+ if not file_path and not data:
331
+ raise click.UsageError("Please provide either a file path or data string")
332
+
333
+ entry_data = None
334
+ is_bulk_update = False
335
+
336
+ if file_path:
337
+ click.echo(f"Loading data from file: {file_path}")
338
+ entry_data = load_file_based_on_extension(file_path)
339
+
340
+ # Check if we got a list or a single object
341
+ if isinstance(entry_data, list):
342
+ is_bulk_update = True
343
+ click.echo(f"Found {len(entry_data)} entries to update")
344
+ else:
345
+ # If we got a single object but no id/code was provided, check if it has Id or Code in data
346
+ if not id and not code:
347
+ data_id = next((entry_data.get(key) for key in entry_data if key.lower() == 'id'), None)
348
+ data_code = next((entry_data.get(key) for key in entry_data if key.lower() == 'code'), None)
349
+ if not data_id and not data_code:
350
+ raise click.UsageError("No --id or --code provided and entry data doesn't contain an Id or Code field")
351
+ id = id or data_id
352
+ code = code or data_code
353
+
354
+ elif data:
355
+ click.echo("Parsing data string")
356
+ entry_data = parse_data_string(data)
357
+
358
+ # Check if we got a list or a single object
359
+ if isinstance(entry_data, list):
360
+ is_bulk_update = True
361
+ click.echo(f"Found {len(entry_data)} entries to update")
362
+ elif not id and not code:
363
+ # If we got a single object but no id/code was provided, check if it has Id or Code in data
364
+ data_id = next((entry_data.get(key) for key in entry_data if key.lower() == 'id'), None)
365
+ data_code = next((entry_data.get(key) for key in entry_data if key.lower() == 'code'), None)
366
+ if not data_id and not data_code:
367
+ raise click.UsageError("No --id or --code provided and data doesn't contain an Id or Code field")
368
+ id = id or data_id
369
+ code = code or data_code
370
+
371
+ if not entry_data:
372
+ raise click.UsageError("No valid entry data found")
373
+
374
+ # For single entry update, require id or code
375
+ if not is_bulk_update and not id and not code:
376
+ raise click.UsageError("Please provide either --id or --code, or include Id or Code in the data")
377
+
378
+ # Update the entries
379
+ if is_bulk_update:
380
+ results = das_ctx.entry_manager.update(entries=entry_data)
381
+
382
+ # Display results
383
+ success_count = sum(1 for result in results if result.get('status') == 'success')
384
+ error_count = len(results) - success_count
385
+
386
+ if success_count > 0:
387
+ click.secho(f"✓ Successfully updated {success_count} entries", fg="green")
388
+
389
+ if error_count > 0:
390
+ click.secho(f"✗ Failed to update {error_count} entries", fg="red")
391
+
392
+ # Show details of the failures
393
+ click.echo("\nFailed updates:")
394
+ for result in results:
395
+ if result.get('status') == 'error':
396
+ ident = result.get('code') or result.get('id') or 'Unknown'
397
+ click.echo(f" {ident}: {result.get('error', 'Unknown error')}")
398
+ else:
399
+ # Single entry update
400
+ results = das_ctx.entry_manager.update(id=id, code=code, entry=entry_data)
401
+
402
+ if results and results[0].get('status') == 'success':
403
+ identifier = id or code
404
+ id_label = "ID" if id else "code"
405
+ click.secho(f"✓ Entry '{identifier}' ({id_label}) updated successfully!", fg="green")
406
+ click.echo(f"Entry ID: {results[0].get('id')}")
407
+ else:
408
+ error_msg = results[0].get('error', 'No response from server') if results else 'No response from server'
409
+ click.secho(f"Entry update failed: {error_msg}", fg="red")
410
+
411
+ except Exception as e:
412
+ click.secho(f"Error: {e}", fg="red")
413
+
414
+ @entry.command("delete")
415
+ @click.option('--code', default=None, help='Entry code')
416
+ @click.option('--id', default=None, help='Entry ID')
417
+ @click.option('--force', is_flag=True, help='Skip confirmation prompt')
418
+ @pass_das_context
419
+ def delete_entry(das_ctx, code, id, force):
420
+ """Delete an entry by its code or ID"""
421
+ if not code and not id:
422
+ raise click.UsageError("Please provide either an entry code or ID")
423
+
424
+ # Ensure client and entry_manager are initialized
425
+ das_ctx.get_client()
426
+
427
+ identifier = code or id
428
+ id_label = "code" if code else "ID"
429
+
430
+ if not force:
431
+ if not click.confirm(f"Are you sure you want to delete entry with {id_label} '{identifier}'?"):
432
+ click.echo("Operation cancelled.")
433
+ return
434
+
435
+ try:
436
+ das_ctx.entry_manager.delete(id=id, code=code)
437
+ click.secho(f"✓ Entry with {id_label} '{identifier}' deleted!", fg="green")
438
+ except Exception as e:
439
+ click.secho(f"Error: {e}", fg="red")
440
+
441
+ @entry.command("link-digital-objects")
442
+ @click.option('--entry-code', required=True, help='Entry code to link/unlink digital objects to')
443
+ @click.option('--digital-object-code', '-d', multiple=True, required=True, help='Digital object code. Use multiple times for multiple objects.')
444
+ @click.option('--unlink', is_flag=True, help='Unlink specified digital objects from the entry instead of linking')
445
+ @pass_das_context
446
+ def link_digital_objects(das_ctx, entry_code, digital_object_code, unlink):
447
+ """Link or unlink existing digital objects to an entry by their codes.
448
+
449
+ Examples:
450
+
451
+ \b
452
+ # Link two digital objects to an entry
453
+ das entry link-digital-objects --entry-code ENT001 -d DO001 -d DO002
454
+
455
+ \b
456
+ # Unlink a digital object from an entry
457
+ das entry link-digital-objects --entry-code ENT001 -d DO003 --unlink
458
+ """
459
+ try:
460
+ das_ctx.get_client()
461
+ codes = list(digital_object_code)
462
+ if not codes:
463
+ raise click.UsageError("Please provide at least one --digital-object-code")
464
+
465
+ success = das_ctx.digital_objects_manager.link_existing_digital_objects(
466
+ entry_code=entry_code,
467
+ digital_object_code_list=codes,
468
+ is_unlink=unlink,
469
+ )
470
+
471
+ if success:
472
+ action = "unlinked" if unlink else "linked"
473
+ click.secho(f"✓ Successfully {action} {len(codes)} digital object(s) for entry '{entry_code}'", fg="green")
474
+ else:
475
+ click.secho("Operation did not report success.", fg="yellow")
476
+ except Exception as e:
477
+ click.secho(f"Error: {e}", fg="red")
478
+
479
+ @entry.command("create")
480
+ @click.option('--attribute', required=True, help='Attribute name')
481
+ @click.argument('file_path', required=False)
482
+ @click.option('--data', help='Data string in format { "key1": "value1", "key2": "value2", ... } or a list of such objects')
483
+ @pass_das_context
484
+ def create_entry(das_ctx, attribute, file_path=None, data=None):
485
+ """Create one or more entries from file or data string
486
+
487
+ Files can contain multiple entries (rows in CSV/XLS or list of objects in JSON).
488
+
489
+ Examples:
490
+
491
+ \b
492
+ # Create entries from JSON file
493
+ das entry create --attribute core c:\\data\\entries.json
494
+
495
+ \b
496
+ # Create entries from CSV file
497
+ das entry create --attribute core c:\\data\\entries.csv
498
+
499
+ \b
500
+ # Create entries from Excel file
501
+ das entry create --attribute core c:\\data\\entries.xls
502
+
503
+ \b
504
+ # Create a single entry from data string
505
+ das entry create --attribute core --data { 'Grant Public Access': Yes, ... }
506
+
507
+ \b
508
+ # Create multiple entries from data string
509
+ das entry create --attribute core --data [{ 'Name': 'Entry 1', ... }, { 'Name': 'Entry 2', ... }]
510
+ """
511
+ try:
512
+ # Ensure client and entry_manager are initialized
513
+ das_ctx.get_client()
514
+ if not file_path and not data:
515
+ raise click.UsageError("Please provide either a file path or data string")
516
+
517
+ entry_data = None
518
+ is_bulk_create = False
519
+
520
+ if file_path:
521
+ click.echo(f"Loading data from file: {file_path}")
522
+ entry_data = load_file_based_on_extension(file_path)
523
+
524
+ # Check if we got a list or a single object
525
+ if isinstance(entry_data, list):
526
+ is_bulk_create = True
527
+ click.echo(f"Found {len(entry_data)} entries to create")
528
+
529
+ elif data:
530
+ click.echo("Parsing data string")
531
+ entry_data = parse_data_string(data)
532
+
533
+ # Check if we got a list or a single object
534
+ if isinstance(entry_data, list):
535
+ is_bulk_create = True
536
+ click.echo(f"Found {len(entry_data)} entries to create")
537
+
538
+ if not entry_data:
539
+ raise click.UsageError("No valid entry data found")
540
+
541
+ # Create the entries
542
+ if is_bulk_create:
543
+ results = das_ctx.entry_manager.create(attribute=attribute, entries=entry_data)
544
+
545
+ # Display results
546
+ success_count = sum(1 for result in results if result.get('status') == 'success')
547
+ error_count = len(results) - success_count
548
+
549
+ if success_count > 0:
550
+ click.secho(f"✓ Successfully created {success_count} entries", fg="green")
551
+
552
+ # Show IDs of created entries
553
+ click.echo("\nCreated entry IDs:")
554
+ for result in results:
555
+ if result.get('status') == 'success':
556
+ click.echo(f" {result.get('id')}")
557
+
558
+ if error_count > 0:
559
+ click.secho(f"✗ Failed to create {error_count} entries", fg="red")
560
+
561
+ # Show details of the failures
562
+ click.echo("\nFailed creations:")
563
+ for i, result in enumerate(results):
564
+ if result.get('status') == 'error':
565
+ click.echo(f" Entry #{i+1}: {result.get('error', 'Unknown error')}")
566
+ else:
567
+ # Single entry creation
568
+ results = das_ctx.entry_manager.create(attribute=attribute, entry=entry_data)
569
+
570
+ if results and results[0].get('status') == 'success':
571
+ click.secho(f"✓ Entry created successfully!", fg="green")
572
+ click.echo(f"Entry ID: {results[0].get('id')}")
573
+ else:
574
+ error_msg = results[0].get('error', 'No ID returned') if results else 'No ID returned'
575
+ click.secho(f"Entry creation failed: {error_msg}", fg="red")
576
+
577
+ except Exception as e:
578
+ click.secho(f"Error: {e}", fg="red")
579
+
580
+ @entry.command("upload-digital-object")
581
+ @click.option('--entry-code', required=True, help='Entry code to attach the digital object to')
582
+ @click.option('--type', 'digital_object_type', required=True, help='Digital object type name (e.g., Dataset, File, Image)')
583
+ @click.option('--description', 'file_description', default='', help='Description for the uploaded file')
584
+ @click.argument('file_path', required=True)
585
+ @pass_das_context
586
+ def upload_digital_object(das_ctx, entry_code, digital_object_type, file_description, file_path):
587
+ """Upload a file as a digital object and link it to an entry.
588
+
589
+ Examples:
590
+
591
+ \b
592
+ # Upload a dataset file and link to an entry
593
+ das entry upload-digital-object --entry-code ENT001 --type Dataset --description "CTD raw" c:\\data\\ctd.zip
594
+ """
595
+ try:
596
+ # Ensure services are initialized
597
+ das_ctx.get_client()
598
+
599
+ # Perform upload and link
600
+ digital_object_id = das_ctx.digital_objects_manager.upload_digital_object(
601
+ entry_code=entry_code,
602
+ file_description=file_description,
603
+ digital_object_type=digital_object_type,
604
+ file_path=file_path,
605
+ )
606
+
607
+ if digital_object_id:
608
+ click.secho("✓ Digital object uploaded and linked successfully!", fg="green")
609
+ click.echo(f"Digital Object ID: {digital_object_id}")
610
+ else:
611
+ click.secho("Upload completed but no ID was returned.", fg="yellow")
612
+ except Exception as e:
613
+ click.secho(f"Error: {e}", fg="red")
614
+
615
+ @entry.command("get")
616
+ @click.option('--code', default=None, help='Entry code')
617
+ @click.option('--id', type=int, default=None, help='Entry ID')
618
+ @pass_das_context
619
+ def get_entry(das_ctx, code=None, id=None):
620
+ """Get entry by either its code or ID"""
621
+
622
+ if not code and not id:
623
+ raise click.UsageError("Please provide either an entry code or ID")
624
+
625
+ try:
626
+ # Ensure client and entry_manager are initialized
627
+ das_ctx.get_client()
628
+ # Pass client as a named parameter to avoid conflicts with 'id' parameter
629
+ entry = das_ctx.entry_manager.get(code=code, id=id)
630
+ if entry:
631
+ click.secho("Entry Details:", fg="green", bold=True)
632
+ click.echo("─" * 40)
633
+ for key, value in entry.items():
634
+ if key == 'Digital Object(s)' and isinstance(value, list):
635
+ continue
636
+ elif isinstance(value, list):
637
+ click.echo(f"{key}:")
638
+ for item in value:
639
+ click.echo(f" - {item}")
640
+ else:
641
+ click.echo(f"{key}: {value or ''}")
642
+
643
+ # if entry contains key: Digital Object(s) and its a list, print each object details
644
+ # with indentation for better readability
645
+ if 'Digital Object(s)' in entry and isinstance(entry['Digital Object(s)'], list):
646
+ click.echo()
647
+ click.echo("─" * 40)
648
+ click.echo("Digital Object(s):")
649
+ for obj in entry['Digital Object(s)']:
650
+ click.echo(f" - ID: {obj.get('Id')}")
651
+ click.echo(f" Name: {obj.get('Name')}")
652
+ click.echo(f" Type: {obj.get('Type')}")
653
+ click.echo(f" Links: {obj.get('Links')}")
654
+ click.echo()
655
+ click.echo("─" * 40)
656
+ else:
657
+ click.secho("Entry not found.", fg="yellow")
658
+ click.echo("Please check the entry code or ID.")
659
+
660
+ except Exception as e:
661
+ click.secho(f"Error: {e}", fg="red")
662
+
663
+ @entry.command("chown")
664
+ @click.option('--user', 'user_name', required=True, help='New owner username')
665
+ @click.option('--code', '-c', multiple=True, required=True, help='Entry code to transfer. Can be used multiple times.')
666
+ @pass_das_context
667
+ def chown_entries(das_ctx, user_name, code):
668
+ """Change ownership of one or more entries by their codes.
669
+
670
+ Example:
671
+
672
+ \b
673
+ das entry chown --user alice --code ENT001 --code ENT002
674
+ """
675
+ try:
676
+ # Ensure services are initialized
677
+ das_ctx.get_client()
678
+
679
+ entry_codes = list(code)
680
+ if not entry_codes:
681
+ raise click.UsageError("Please provide at least one --code")
682
+
683
+ result = das_ctx.entry_manager.chown(user_name=user_name, entry_code_list=entry_codes)
684
+
685
+ # If API returns a plain success or list, just report success
686
+ click.secho("✓ Ownership updated successfully!", fg="green")
687
+ if isinstance(result, dict):
688
+ # Show minimal feedback if available
689
+ updated = result.get('updated') or result.get('result') or result
690
+ if updated:
691
+ click.echo(json.dumps(updated, indent=2))
692
+ except Exception as e:
693
+ click.secho(f"Error: {e}", fg="red")
694
+
695
+ # Attribute commands group
696
+ @cli.group()
697
+ def attribute():
698
+ """Commands for working with attributes"""
699
+ pass
700
+
701
+ # Cache commands group
702
+ @cli.group()
703
+ def cache():
704
+ """Commands for working with cache"""
705
+ pass
706
+
707
+ # Config commands group
708
+ @cli.group()
709
+ def config():
710
+ """Commands for configuring the CLI"""
711
+ pass
712
+
713
+ @cache.command("clear-all")
714
+ @pass_das_context
715
+ def clear_all_cache(das_ctx):
716
+ """Clear all cache entries"""
717
+ client = das_ctx.get_client()
718
+ try:
719
+ result = client.cache.clear_all()
720
+ if result.get('success'):
721
+ click.secho("✓ All cache cleared!", fg="green")
722
+ else:
723
+ click.secho("Failed to clear cache.", fg="red")
724
+ except Exception as e:
725
+ click.secho(f"Error: {e}", fg="red")
726
+
727
+ @cache.command("list")
728
+ @pass_das_context
729
+ def list_cache(das_ctx):
730
+ """List all cache entries"""
731
+ client = das_ctx.get_client()
732
+ try:
733
+ caches = client.cache.get_all()
734
+ if caches:
735
+ click.secho("Cache Entries:", fg="green", bold=True)
736
+ click.echo("─" * 40)
737
+ # Sort cache items by name before displaying
738
+ sorted_caches = sorted(caches.get('items', []), key=lambda x: x.get('name', '').lower())
739
+ for cache in sorted_caches:
740
+ click.echo(f"Name: {cache.get('name')}")
741
+ click.echo("─" * 40)
742
+ else:
743
+ click.secho("No cache entries found.", fg="yellow")
744
+ except Exception as e:
745
+ click.secho(f"Error: {e}", fg="red")
746
+
747
+ @cache.command("clear")
748
+ @click.argument('name', required=True)
749
+ @pass_das_context
750
+ def clear_cache(das_ctx, name):
751
+ """Clear a specific cache by name"""
752
+ client = das_ctx.get_client()
753
+ try:
754
+ result = client.cache.clear_cache(name)
755
+ if result.get('success'):
756
+ click.secho(f"✓ Cache '{name}' cleared!", fg="green")
757
+ else:
758
+ click.secho(f"Failed to clear cache '{name}'.", fg="red")
759
+ except Exception as e:
760
+ click.secho(f"Error: {e}", fg="red")
761
+
762
+ @attribute.command("get")
763
+ @click.option('--id', type=int, default=None, help='Attribute ID')
764
+ @click.option('--name', default=None, help='Attribute name')
765
+ @click.option('--alias', default=None, help='Attribute alias')
766
+ @click.option('--table-name', default=None, help='Table name')
767
+ @pass_das_context
768
+ def get_attribute(das_ctx, id, name, alias, table_name):
769
+ """Get attribute by ID, name, alias, or table name"""
770
+ if not any([id, name, alias, table_name]):
771
+ raise click.UsageError("Please provide at least one search parameter")
772
+
773
+ client = das_ctx.get_client()
774
+ try:
775
+ result = client.attributes.get_attribute(id=id, name=name, alias=alias, table_name=table_name)
776
+ if result.get('success') and result.get('result', {}).get('items'):
777
+ attributes = result['result']['items']
778
+ for attr in attributes:
779
+ click.secho("\nAttribute Details:", fg="green", bold=True)
780
+ click.echo("─" * 40)
781
+ click.echo(f"ID: {attr.get('id')}")
782
+ click.echo(f"Name: {attr.get('name')}")
783
+ click.echo(f"Description: {attr.get('description')}")
784
+ click.echo(f"Alias: {attr.get('alias')}")
785
+ click.echo(f"Table Name: {attr.get('tableName')}")
786
+ click.echo(f"Menu Name: {attr.get('menuName')}")
787
+ click.echo(f"Context: {attr.get('contextName')}")
788
+ click.echo(f"Indexable: {attr.get('isIndexable')}")
789
+ if attr.get('indexName'):
790
+ click.echo(f"Index Name: {attr.get('indexName')}")
791
+ click.echo("─" * 40)
792
+ else:
793
+ click.secho("No attributes found.", fg="yellow")
794
+ except Exception as e:
795
+ click.secho(f"Error: {e}", fg="red")
796
+
797
+
798
+ @attribute.command("get-name")
799
+ @click.argument('id', type=int, required=True)
800
+ @pass_das_context
801
+ def get_attribute_name(das_ctx, id):
802
+ """Get attribute name by ID"""
803
+ client = das_ctx.get_client()
804
+ try:
805
+ name = client.attributes.get_name(id)
806
+ click.echo(name)
807
+ except Exception as e:
808
+ click.secho(f"Error: {e}", fg="red")
809
+
810
+ @attribute.command("get-id")
811
+ @click.argument('name', required=True)
812
+ @pass_das_context
813
+ def get_attribute_id(das_ctx, name):
814
+ """Get attribute ID by name"""
815
+ client = das_ctx.get_client()
816
+ try:
817
+ attr_id = client.attributes.get_id(name)
818
+ click.echo(attr_id)
819
+ except Exception as e:
820
+ click.secho(f"Error: {e}", fg="red")
821
+
822
+ # SSL verification commands
823
+ @config.command("ssl-verify")
824
+ @click.argument('enabled', type=click.Choice(['true', 'false']), required=True)
825
+ def set_ssl_verify(enabled):
826
+ """Set SSL certificate verification (true/false)"""
827
+ verify = enabled.lower() == 'true'
828
+ save_verify_ssl(verify)
829
+ status = "enabled" if verify else "disabled"
830
+ click.echo(f"SSL certificate verification {status}")
831
+
832
+ @config.command("ssl-status")
833
+ def get_ssl_status():
834
+ """Show current SSL certificate verification status"""
835
+ status = "enabled" if VERIFY_SSL else "disabled"
836
+ click.echo(f"SSL certificate verification is currently {status}")
837
+
838
+ @config.command("reset")
839
+ @click.option('--force', is_flag=True, help='Skip confirmation prompt')
840
+ def reset_config(force):
841
+ """Clear all configuration files (token, URL, and authentication info)"""
842
+ if not force:
843
+ if not click.confirm("This will remove all saved credentials and configuration. Are you sure?"):
844
+ click.echo("Operation cancelled.")
845
+ return
846
+
847
+ from das.common.config import clear_token, _config_dir
848
+ import shutil
849
+
850
+ # Clear token (handles both keyring and file-based storage)
851
+ clear_token()
852
+
853
+ # Get the config directory path
854
+ config_dir = _config_dir()
855
+
856
+ # Check if the directory exists
857
+ if config_dir.exists():
858
+ # Remove all files in the config directory
859
+ for file_path in config_dir.glob("*"):
860
+ if file_path.is_file():
861
+ try:
862
+ file_path.unlink()
863
+ click.echo(f"Removed: {file_path.name}")
864
+ except Exception as e:
865
+ click.secho(f"Failed to remove {file_path.name}: {e}", fg="red")
866
+
867
+ click.secho("✓ All configuration files and credentials have been removed.", fg="green")
868
+
869
+ # Download commands group
870
+ @cli.group()
871
+ def download():
872
+ """Commands for working with downloads"""
873
+ pass
874
+
875
+ @download.command("request")
876
+ @click.option('--name', help='Name for the download request (defaults to timestamp if not provided)')
877
+ @click.option('--entry', '-e', multiple=True, required=True, help='Entry code to download files from. Can be used multiple times.')
878
+ @click.option('--file', '-f', multiple=True, help='File codes to download. If not specified, all files will be downloaded.')
879
+ @click.option('--from-file', help='Load download request from a JSON file')
880
+ @click.option('--format', 'output_format', default='table', type=click.Choice(['table', 'json']), help='Output format (table or json)')
881
+ @pass_das_context
882
+ def create_download_request(das_ctx, name, entry, file, from_file, output_format):
883
+ """
884
+ Create a new download request for specified entries and files
885
+
886
+ Examples:
887
+
888
+ \b
889
+ # Download all files from an entry
890
+ das download request --entry ENT001
891
+
892
+ \b
893
+ # Download specific files from an entry
894
+ das download request --entry ENT001 --file FILE001 --file FILE002
895
+
896
+ \b
897
+ # Download from multiple entries
898
+ das download request --entry ENT001 --entry ENT002 --name "My Download"
899
+
900
+ \b
901
+ # Download specific files from multiple entries
902
+ das download request --entry ENT001 --file FILE001 --entry ENT002 --file FILE003
903
+
904
+ \b
905
+ # Download using a JSON file specification
906
+ das download request --from-file request.json
907
+ """
908
+ try:
909
+ request_data = {}
910
+
911
+ # Handle name parameter
912
+ if name:
913
+ request_data['name'] = name
914
+
915
+ # Handle from-file parameter
916
+ if from_file:
917
+ click.echo(f"Loading download request from file: {from_file}")
918
+ try:
919
+ with open(from_file, 'r') as f:
920
+ file_data = json.load(f)
921
+ request_data.update(file_data)
922
+ except Exception as e:
923
+ raise click.UsageError(f"Failed to load file: {e}")
924
+ # Handle entry and file parameters
925
+ else:
926
+ # Group files by entry
927
+ current_entry = None
928
+ entry_files = {}
929
+
930
+ # If we have entries and files, we need to pair them correctly
931
+ if entry and file:
932
+ for arg in sys.argv:
933
+ if arg == '--entry' or arg == '-e':
934
+ # Next arg will be an entry code
935
+ current_entry = None
936
+ elif arg == '--file' or arg == '-f':
937
+ # Next arg will be a file code
938
+ pass
939
+ elif current_entry is None and arg in entry:
940
+ # This is an entry code
941
+ current_entry = arg
942
+ if current_entry not in entry_files:
943
+ entry_files[current_entry] = []
944
+ elif current_entry is not None and arg in file:
945
+ # This is a file code for the current entry
946
+ entry_files[current_entry].append(arg)
947
+ else:
948
+ # If we have entries but no files, download all files for each entry
949
+ for e in entry:
950
+ entry_files[e] = []
951
+
952
+ # Update request_data with the entry files
953
+ for e, files in entry_files.items():
954
+ request_data[e] = files
955
+
956
+ if not request_data or all(key == 'name' for key in request_data.keys()):
957
+ raise click.UsageError("No download request data provided")
958
+
959
+ # Execute the download request
960
+ # Make sure client and download_manager are initialized
961
+ das_ctx.get_client()
962
+ result = das_ctx.download_manager.create_download_request(request_data)
963
+
964
+ # Check if result contains errors
965
+ if isinstance(result, dict) and 'errors' in result:
966
+ click.secho("Download request failed with errors:", fg="red")
967
+ for error in result['errors']:
968
+ click.secho(f"- {error}", fg="red")
969
+ return
970
+
971
+ # Handle the result based on its type
972
+ if output_format == 'json':
973
+ # If result is a string (just the ID), wrap it in a dict for consistent JSON output
974
+ if isinstance(result, str):
975
+ click.echo(json.dumps({"id": result}, indent=2))
976
+ else:
977
+ click.echo(json.dumps(result, indent=2))
978
+ else:
979
+ click.secho("✓ Download request created successfully!", fg="green")
980
+
981
+ # If result is a string, it's just the request ID
982
+ if isinstance(result, str):
983
+ request_id = result
984
+ click.echo(f"Request ID: {request_id}")
985
+
986
+ # Show download instructions
987
+ click.echo("\nUse the following command to check the status of your download:")
988
+ click.secho(f" das download status {request_id}", fg="cyan")
989
+ else:
990
+ # Handle the case where result is a dictionary with more information
991
+ request_id = result.get('id', 'Unknown')
992
+ click.echo(f"Request ID: {request_id}")
993
+ click.echo(f"Status: {result.get('status', 'Pending')}")
994
+
995
+ # Display files in the request if available
996
+ if result.get('items'):
997
+ click.secho("\nFiles in this download request:", fg="blue")
998
+
999
+ headers = ["#", "File Name", "Entry", "Size", "Status"]
1000
+ table_data = []
1001
+
1002
+ for i, item in enumerate(result.get('items', []), 1):
1003
+ file_info = item.get('fileInfo', {})
1004
+ table_data.append([
1005
+ i,
1006
+ file_info.get('name', 'Unknown'),
1007
+ file_info.get('entryCode', 'Unknown'),
1008
+ file_info.get('size', 'Unknown'),
1009
+ item.get('status', 'Pending')
1010
+ ])
1011
+
1012
+ click.echo(format_table(table_data, headers))
1013
+
1014
+ # Show download instructions
1015
+ click.echo("\nUse the following command to check the status of your download:")
1016
+ click.secho(f" das download status {request_id}", fg="cyan")
1017
+
1018
+ except Exception as e:
1019
+ click.secho(f"Error: {e}", fg="red")
1020
+
1021
+ @download.command("files")
1022
+ @click.argument('request_id', required=True)
1023
+ @click.option('--out', 'output_path', required=False, default='.', help='Output file path or directory (defaults to current directory)')
1024
+ @click.option('--force', is_flag=True, help='Overwrite existing file if present')
1025
+ @pass_das_context
1026
+ def download_files(das_ctx, request_id, output_path, force):
1027
+ """
1028
+ Download the completed bundle for a download request and save it to disk.
1029
+
1030
+ Examples:
1031
+
1032
+ \b
1033
+ # Save into current directory with server-provided filename
1034
+ das download files 6b0e68e6-00cd-43a7-9c51-d56c9c091123
1035
+
1036
+ \b
1037
+ # Save to a specific folder
1038
+ das download files 6b0e68e6-00cd-43a7-9c51-d56c9c091123 --out C:\\Downloads
1039
+
1040
+ \b
1041
+ # Save to an explicit filename, overwriting if exists
1042
+ das download files 6b0e68e6-00cd-43a7-9c51-d56c9c091123 --out C:\\Downloads\\bundle.zip --force
1043
+ """
1044
+ try:
1045
+ das_ctx.get_client()
1046
+ saved_path = das_ctx.download_manager.save_download(request_id=request_id, output_path=output_path, overwrite=force)
1047
+ click.secho(f"✓ Download saved to: {saved_path}", fg="green")
1048
+ except FileExistsError as e:
1049
+ click.secho(str(e), fg="yellow")
1050
+ except Exception as e:
1051
+ click.secho(f"Error: {e}", fg="red")
1052
+
1053
+ @download.command("delete-request")
1054
+ @click.argument('request_id', required=True)
1055
+ @pass_das_context
1056
+ def delete_download_request(das_ctx, request_id):
1057
+ """
1058
+ Delete a download request by its ID
1059
+
1060
+ Example:
1061
+
1062
+ \b
1063
+ # Delete a download request
1064
+ das download delete-request 6b0e68e6-00cd-43a7-9c51-d56c9c091123
1065
+ """
1066
+ try:
1067
+ # Ensure client and download_manager are initialized
1068
+ das_ctx.get_client()
1069
+
1070
+ # Call the delete_download_request method
1071
+ result = das_ctx.download_manager.delete_download_request(request_id)
1072
+
1073
+ # Display success message
1074
+ click.secho(f"✓ Download request '{request_id}' deleted successfully!", fg="green")
1075
+
1076
+ except Exception as e:
1077
+ click.secho(f"Error: {e}", fg="red")
1078
+
1079
+
1080
+ @download.command("my-requests")
1081
+ @click.option('--format', 'output_format', default='table', type=click.Choice(['table', 'json']), help='Output format (table or json)')
1082
+ @pass_das_context
1083
+ def list_my_download_requests(das_ctx, output_format):
1084
+ """List your download requests in a user-friendly format"""
1085
+ try:
1086
+ # Ensure services are initialized
1087
+ das_ctx.get_client()
1088
+ result = das_ctx.download_manager.get_my_requests()
1089
+
1090
+ # Normalize result shape
1091
+ total_count = 0
1092
+ items = []
1093
+ if isinstance(result, dict):
1094
+ total_count = result.get('totalCount', 0)
1095
+ items = result.get('items', [])
1096
+ elif isinstance(result, list):
1097
+ items = result
1098
+ total_count = len(items)
1099
+
1100
+ if output_format == 'json':
1101
+ payload = { 'totalCount': total_count, 'items': items }
1102
+ click.echo(json.dumps(payload, indent=2))
1103
+ return
1104
+
1105
+ if not items:
1106
+ click.secho("No download requests found.", fg="yellow")
1107
+ return
1108
+
1109
+ # Build a compact table
1110
+ headers = ["#", "ID", "Requester", "Created", "Status", "Files"]
1111
+ table_data = []
1112
+
1113
+ from das.common.enums import DownloadRequestStatus
1114
+
1115
+ def map_status(code):
1116
+ try:
1117
+ return DownloadRequestStatus(code).name.replace('_', ' ').title()
1118
+ except Exception:
1119
+ return str(code)
1120
+
1121
+ def fmt_dt(dt_str):
1122
+ if not isinstance(dt_str, str) or 'T' not in dt_str:
1123
+ return dt_str or ''
1124
+ date_part = dt_str.split('T')[0]
1125
+ time_part = dt_str.split('T')[1].split('.')[0]
1126
+ return f"{date_part} {time_part}"
1127
+
1128
+ for i, req in enumerate(items, 1):
1129
+ req_id = req.get('id', '')
1130
+ requester = req.get('requester', '')
1131
+ created = fmt_dt(req.get('createdOn'))
1132
+ status = map_status(req.get('status'))
1133
+ files = req.get('files') or []
1134
+ file_count = len(files)
1135
+ table_data.append([
1136
+ i,
1137
+ req_id[:8],
1138
+ requester.split(' - ')[0] if isinstance(requester, str) else requester,
1139
+ created,
1140
+ status,
1141
+ file_count
1142
+ ])
1143
+
1144
+ click.secho(f"\nYour download requests ({total_count})", fg="blue")
1145
+ click.echo(format_table(table_data, headers))
1146
+
1147
+ # Show a brief file breakdown below the table
1148
+ click.secho("\nDetails:", fg="blue")
1149
+ for i, req in enumerate(items, 1):
1150
+ files = req.get('files') or []
1151
+ if not files:
1152
+ continue
1153
+ click.echo(f"{i}. {req.get('id', '')}")
1154
+ for f in files[:5]:
1155
+ # Map item status if present
1156
+ status_code = f.get('status')
1157
+ status_label = None
1158
+ try:
1159
+ from das.common.enums import DownloadRequestItemStatus
1160
+ status_label = DownloadRequestItemStatus(status_code).name.replace('_', ' ').title()
1161
+ except Exception:
1162
+ status_label = str(status_code)
1163
+ click.echo(f" - {f.get('fileName', f.get('needle', ''))} [{status_label}] ({f.get('digitalObjectType', '')})")
1164
+ if len(files) > 5:
1165
+ click.echo(f" ... and {len(files) - 5} more")
1166
+
1167
+ except Exception as e:
1168
+ click.secho(f"Error: {e}", fg="red")
1169
+
1170
+ # DAS AI commands group
1171
+ @cli.group()
1172
+ def ai():
1173
+ """Commands for working with DAS AI"""
1174
+ pass
1175
+
1176
+ @ai.command("enable")
1177
+ @pass_das_context
1178
+ def enable_das_ai(das_ctx):
1179
+ """Enable DAS AI interactive mode"""
1180
+ try:
1181
+ # Ensure OpenAI API key is configured
1182
+ api_key = os.getenv("OPENAI_API_KEY") or load_openai_api_key()
1183
+ if not api_key:
1184
+ click.secho("No OpenAI API key found.", fg="yellow")
1185
+ click.echo("You can set it via environment variable OPENAI_API_KEY, or save it now.")
1186
+ key = click.prompt("Enter your OpenAI API key", hide_input=True)
1187
+ if not key:
1188
+ raise click.UsageError("OpenAI API key is required to enable DAS AI.")
1189
+ save_openai_api_key(key)
1190
+ click.secho("✓ OpenAI API key saved securely.", fg="green")
1191
+
1192
+ # Get DAS AI instance
1193
+ das_ai = das_ctx.get_das_ai()
1194
+
1195
+ click.secho("🤖 DAS AI is now enabled!", fg="green", bold=True)
1196
+ click.echo("Starting interactive AI session...")
1197
+ click.echo("Type 'exit' to quit the AI session.")
1198
+ click.echo("=" * 50)
1199
+
1200
+ # Run the AI main loop
1201
+ import asyncio
1202
+ asyncio.run(das_ai.main())
1203
+
1204
+ except Exception as e:
1205
+ click.secho(f"Error enabling DAS AI: {e}", fg="red")
1206
+ click.echo("Make sure you have set your OPENAI_API_KEY environment variable.")
1207
+
1208
+ def _ai_clear_impl(force: bool):
1209
+ if not force:
1210
+ if not click.confirm("This will remove your saved OpenAI API key and authentication token. Continue?"):
1211
+ click.echo("Operation cancelled.")
1212
+ return
1213
+ try:
1214
+ clear_openai_api_key()
1215
+ clear_token()
1216
+ cfg_dir = _config_dir()
1217
+ removed_any = False
1218
+ for fname in ["openai_key.json", "token.json"]:
1219
+ p = cfg_dir / fname
1220
+ if p.exists() and p.is_file():
1221
+ try:
1222
+ p.unlink()
1223
+ removed_any = True
1224
+ except Exception:
1225
+ pass
1226
+ click.secho("✓ DAS AI credentials cleared.", fg="green")
1227
+ if removed_any:
1228
+ click.echo("Local credential files removed.")
1229
+ except Exception as e:
1230
+ click.secho(f"Error clearing credentials: {e}", fg="red")
1231
+
1232
+ @ai.command("clear")
1233
+ @click.option('--force', is_flag=True, help='Skip confirmation prompt')
1234
+ def ai_clear(force):
1235
+ """Clear DAS AI credentials (OpenAI key) and auth token."""
1236
+ _ai_clear_impl(force)
1237
+
1238
+ @ai.command("logout")
1239
+ @click.option('--force', is_flag=True, help='Skip confirmation prompt')
1240
+ def ai_logout(force):
1241
+ """Alias for 'das ai clear'"""
1242
+ _ai_clear_impl(force)
1243
+
1244
+ if __name__ == "__main__":
1245
+ cli()