Skip to content

Commit 5a5cd2d

Browse files
committed
Support machine-readable token CLI output
1 parent c067307 commit 5a5cd2d

File tree

2 files changed

+242
-48
lines changed

2 files changed

+242
-48
lines changed

agent_memory_server/cli.py

Lines changed: 138 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import importlib
6+
import json
67
import sys
78
from datetime import UTC, datetime, timedelta
89

@@ -305,7 +306,15 @@ def token():
305306
@token.command()
306307
@click.option("--description", "-d", required=True, help="Token description")
307308
@click.option("--expires-days", "-e", type=int, help="Token expiration in days")
308-
def add(description: str, expires_days: int | None):
309+
@click.option(
310+
"--format",
311+
"output_format",
312+
type=click.Choice(["text", "json"]),
313+
default="text",
314+
show_default=True,
315+
help="Output format.",
316+
)
317+
def add(description: str, expires_days: int | None, output_format: str):
309318
"""Add a new authentication token."""
310319
import asyncio
311320

@@ -344,20 +353,43 @@ async def create_token():
344353
list_key = Keys.auth_tokens_list_key()
345354
await redis.sadd(list_key, token_hash)
346355

356+
return token, token_info
357+
358+
token, token_info = asyncio.run(create_token())
359+
360+
if output_format == "json":
361+
data = {
362+
"token": token,
363+
"description": token_info.description,
364+
"created_at": token_info.created_at.isoformat(),
365+
"expires_at": token_info.expires_at.isoformat()
366+
if token_info.expires_at
367+
else None,
368+
"hash": token_info.token_hash,
369+
}
370+
click.echo(json.dumps(data))
371+
else:
372+
expires_at = token_info.expires_at
347373
click.echo("Token created successfully!")
348374
click.echo(f"Token: {token}")
349-
click.echo(f"Description: {description}")
375+
click.echo(f"Description: {token_info.description}")
350376
if expires_at:
351377
click.echo(f"Expires: {expires_at.isoformat()}")
352378
else:
353379
click.echo("Expires: Never")
354380
click.echo("\nWARNING: Save this token securely. It will not be shown again.")
355381

356-
asyncio.run(create_token())
357-
358382

359383
@token.command()
360-
def list():
384+
@click.option(
385+
"--format",
386+
"output_format",
387+
type=click.Choice(["text", "json"]),
388+
default="text",
389+
show_default=True,
390+
help="Output format.",
391+
)
392+
def list(output_format: str):
361393
"""List all authentication tokens."""
362394
import asyncio
363395

@@ -371,12 +403,16 @@ async def list_tokens():
371403
list_key = Keys.auth_tokens_list_key()
372404
token_hashes = await redis.smembers(list_key)
373405

406+
tokens_data = []
407+
374408
if not token_hashes:
375-
click.echo("No tokens found.")
376-
return
409+
if output_format == "text":
410+
click.echo("No tokens found.")
411+
return tokens_data
377412

378-
click.echo("Authentication Tokens:")
379-
click.echo("=" * 50)
413+
if output_format == "text":
414+
click.echo("Authentication Tokens:")
415+
click.echo("=" * 50)
380416

381417
for token_hash in token_hashes:
382418
key = Keys.auth_token_key(token_hash)
@@ -390,27 +426,57 @@ async def list_tokens():
390426
try:
391427
token_info = TokenInfo.model_validate_json(token_data)
392428

393-
# Mask the token hash for display
394-
masked_hash = token_hash[:8] + "..." + token_hash[-8:]
395-
396-
click.echo(f"Token: {masked_hash}")
397-
click.echo(f"Description: {token_info.description}")
398-
click.echo(f"Created: {token_info.created_at.isoformat()}")
399-
if token_info.expires_at:
400-
click.echo(f"Expires: {token_info.expires_at.isoformat()}")
401-
else:
402-
click.echo("Expires: Never")
403-
click.echo("-" * 30)
429+
tokens_data.append(
430+
{
431+
"hash": token_hash,
432+
"description": token_info.description,
433+
"created_at": token_info.created_at.isoformat(),
434+
"expires_at": token_info.expires_at.isoformat()
435+
if token_info.expires_at
436+
else None,
437+
"expired": bool(
438+
token_info.expires_at
439+
and datetime.now(UTC) > token_info.expires_at
440+
),
441+
}
442+
)
443+
444+
if output_format == "text":
445+
# Mask the token hash for display
446+
masked_hash = token_hash[:8] + "..." + token_hash[-8:]
447+
448+
click.echo(f"Token: {masked_hash}")
449+
click.echo(f"Description: {token_info.description}")
450+
click.echo(f"Created: {token_info.created_at.isoformat()}")
451+
if token_info.expires_at:
452+
click.echo(f"Expires: {token_info.expires_at.isoformat()}")
453+
else:
454+
click.echo("Expires: Never")
455+
click.echo("-" * 30)
404456

405457
except Exception as e:
406-
click.echo(f"Error processing token {token_hash}: {e}")
458+
if output_format == "text":
459+
click.echo(f"Error processing token {token_hash}: {e}")
460+
461+
return tokens_data
462+
463+
tokens_data = asyncio.run(list_tokens())
407464

408-
asyncio.run(list_tokens())
465+
if output_format == "json":
466+
click.echo(json.dumps(tokens_data))
409467

410468

411469
@token.command()
412470
@click.argument("token_hash")
413-
def show(token_hash: str):
471+
@click.option(
472+
"--format",
473+
"output_format",
474+
type=click.Choice(["text", "json"]),
475+
default="text",
476+
show_default=True,
477+
help="Output format.",
478+
)
479+
def show(token_hash: str, output_format: str):
414480
"""Show details for a specific token."""
415481
import asyncio
416482

@@ -430,45 +496,69 @@ async def show_token():
430496
matching_hashes = [h for h in token_hashes if h.startswith(token_hash)]
431497

432498
if not matching_hashes:
433-
click.echo(f"No token found matching '{token_hash}'")
434-
return
499+
if output_format == "text":
500+
click.echo(f"No token found matching '{token_hash}'")
501+
return None
435502
if len(matching_hashes) > 1:
436-
click.echo(f"Multiple tokens match '{token_hash}':")
437-
for h in matching_hashes:
438-
click.echo(f" {h[:8]}...{h[-8:]}")
439-
return
503+
if output_format == "text":
504+
click.echo(f"Multiple tokens match '{token_hash}':")
505+
for h in matching_hashes:
506+
click.echo(f" {h[:8]}...{h[-8:]}")
507+
return None
440508
token_hash = matching_hashes[0]
441509

442510
key = Keys.auth_token_key(token_hash)
443511
token_data = await redis.get(key)
444512

445513
if not token_data:
446-
click.echo(f"Token not found: {token_hash}")
447-
return
514+
if output_format == "text":
515+
click.echo(f"Token not found: {token_hash}")
516+
return None
448517

449518
try:
450519
token_info = TokenInfo.model_validate_json(token_data)
451520

452-
click.echo("Token Details:")
453-
click.echo("=" * 30)
454-
click.echo(f"Hash: {token_hash}")
455-
click.echo(f"Description: {token_info.description}")
456-
click.echo(f"Created: {token_info.created_at.isoformat()}")
457-
if token_info.expires_at:
458-
click.echo(f"Expires: {token_info.expires_at.isoformat()}")
459-
# Check if expired
460-
if datetime.now(UTC) > token_info.expires_at:
461-
click.echo("Status: EXPIRED")
462-
else:
463-
click.echo("Status: Active")
521+
if token_info.expires_at and datetime.now(UTC) > token_info.expires_at:
522+
status = "EXPIRED"
464523
else:
465-
click.echo("Expires: Never")
466-
click.echo("Status: Active")
524+
status = "Active"
467525

468-
except Exception as e:
469-
click.echo(f"Error processing token: {e}")
526+
return token_hash, token_info, status
470527

471-
asyncio.run(show_token())
528+
except Exception as e:
529+
if output_format == "text":
530+
click.echo(f"Error processing token: {e}")
531+
return None
532+
533+
result = asyncio.run(show_token())
534+
535+
if result is None:
536+
return
537+
538+
token_hash, token_info, status = result
539+
540+
if output_format == "json":
541+
data = {
542+
"hash": token_hash,
543+
"description": token_info.description,
544+
"created_at": token_info.created_at.isoformat(),
545+
"expires_at": token_info.expires_at.isoformat()
546+
if token_info.expires_at
547+
else None,
548+
"status": status,
549+
}
550+
click.echo(json.dumps(data))
551+
else:
552+
click.echo("Token Details:")
553+
click.echo("=" * 30)
554+
click.echo(f"Hash: {token_hash}")
555+
click.echo(f"Description: {token_info.description}")
556+
click.echo(f"Created: {token_info.created_at.isoformat()}")
557+
if token_info.expires_at:
558+
click.echo(f"Expires: {token_info.expires_at.isoformat()}")
559+
else:
560+
click.echo("Expires: Never")
561+
click.echo(f"Status: {status}")
472562

473563

474564
@token.command()

tests/test_token_cli.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,41 @@ def test_token_add_command_no_expiry(self, mock_get_redis, mock_redis, cli_runne
6060
mock_redis.expire.assert_not_called() # No expiry set
6161
mock_redis.sadd.assert_called_once()
6262

63+
@patch("agent_memory_server.cli.get_redis_conn")
64+
def test_token_add_command_json_output(
65+
self, mock_get_redis, mock_redis, cli_runner
66+
):
67+
"""Test token add command with JSON output."""
68+
mock_get_redis.return_value = mock_redis
69+
70+
import json
71+
72+
result = cli_runner.invoke(
73+
token,
74+
[
75+
"add",
76+
"--description",
77+
"Test token",
78+
"--expires-days",
79+
"30",
80+
"--format",
81+
"json",
82+
],
83+
)
84+
85+
assert result.exit_code == 0
86+
87+
data = json.loads(result.output)
88+
assert data["description"] == "Test token"
89+
assert data["token"]
90+
assert data["hash"]
91+
assert data["expires_at"] is not None
92+
93+
# Verify Redis calls
94+
mock_redis.set.assert_called_once()
95+
mock_redis.expire.assert_called_once()
96+
mock_redis.sadd.assert_called_once()
97+
6398
@patch("agent_memory_server.cli.get_redis_conn")
6499
def test_token_list_command_empty(self, mock_get_redis, mock_redis, cli_runner):
65100
"""Test token list command with no tokens."""
@@ -97,6 +132,38 @@ def test_token_list_command_with_tokens(
97132
assert "Test token" in result.output
98133
assert "test_has...34567890" in result.output # Masked hash
99134

135+
@patch("agent_memory_server.cli.get_redis_conn")
136+
def test_token_list_command_json_output(
137+
self, mock_get_redis, mock_redis, cli_runner
138+
):
139+
"""Test token list command with JSON output."""
140+
mock_get_redis.return_value = mock_redis
141+
142+
import json
143+
144+
# Create sample token data
145+
token_hash = "test_hash_123456789012345678901234567890"
146+
token_info = TokenInfo(
147+
description="Test token",
148+
created_at=datetime.now(UTC),
149+
expires_at=datetime.now(UTC) + timedelta(days=30),
150+
token_hash=token_hash,
151+
)
152+
153+
mock_redis.smembers.return_value = {token_hash}
154+
mock_redis.get.return_value = token_info.model_dump_json()
155+
156+
result = cli_runner.invoke(token, ["list", "--format", "json"])
157+
158+
assert result.exit_code == 0
159+
160+
data = json.loads(result.output)
161+
assert isinstance(data, list)
162+
assert len(data) == 1
163+
item = data[0]
164+
assert item["hash"] == token_hash
165+
assert item["description"] == "Test token"
166+
100167
@patch("agent_memory_server.cli.get_redis_conn")
101168
def test_token_show_command(self, mock_get_redis, mock_redis, cli_runner):
102169
"""Test token show command."""
@@ -120,6 +187,43 @@ def test_token_show_command(self, mock_get_redis, mock_redis, cli_runner):
120187
assert "Test token" in result.output
121188
assert "Status: Active" in result.output
122189

190+
@patch("agent_memory_server.cli.get_redis_conn")
191+
def test_token_show_command_json_output(
192+
self, mock_get_redis, mock_redis, cli_runner
193+
):
194+
"""Test token show command with JSON output."""
195+
mock_get_redis.return_value = mock_redis
196+
197+
import json
198+
199+
# Create sample token data
200+
token_hash = "test_hash_123456789012345678901234567890"
201+
token_info = TokenInfo(
202+
description="Test token",
203+
created_at=datetime.now(UTC),
204+
expires_at=datetime.now(UTC) + timedelta(days=30),
205+
token_hash=token_hash,
206+
)
207+
208+
mock_redis.get.return_value = token_info.model_dump_json()
209+
210+
result = cli_runner.invoke(
211+
token,
212+
[
213+
"show",
214+
"--format",
215+
"json",
216+
token_hash,
217+
],
218+
)
219+
220+
assert result.exit_code == 0
221+
222+
data = json.loads(result.output)
223+
assert data["hash"] == token_hash
224+
assert data["description"] == "Test token"
225+
assert data["status"] == "Active"
226+
123227
@patch("agent_memory_server.cli.get_redis_conn")
124228
def test_token_show_command_partial_hash(
125229
self, mock_get_redis, mock_redis, cli_runner

0 commit comments

Comments
 (0)