Skip to content

Commit be25361

Browse files
author
Paul Ellis
committed
Improve sql-agent-cli help discoverability
1 parent 03d5895 commit be25361

4 files changed

Lines changed: 153 additions & 23 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ sql-agent-cli "SELECT 1"
3535

3636
## Primary usage
3737

38+
Happy path for agents and humans:
39+
40+
```text
41+
sql-agent-cli "SELECT id, name FROM users LIMIT 10"
42+
```
43+
44+
If a default target is configured, that should usually be the first thing you try.
45+
You normally do not need to inspect config files or hunt for environment details
46+
before running a query.
47+
3848
Default target:
3949

4050
```text

spec.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
Build a new Python CLI named `sql-agent-cli` that runs safe, read-only SQL queries against local or remote databases and emits deterministic, agent-friendly output.
66

7-
The tool should follow the same broad product pattern as `azwi` and `confluence-fetch`:
7+
The tool should follow the same broad product pattern as similar agent-first CLIs such as `confluence-fetch`:
88

99
1. local single-file execution via `uv run ./sql_agent_cli.py ...` using PEP 723 inline script metadata
1010
2. packaged execution via `uvx sql-agent-cli ...`

src/sql_agent/cli.py

Lines changed: 133 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
PROGRAM_NAME = "sql-agent-cli"
2828

2929

30+
class HelpFormatter(argparse.RawDescriptionHelpFormatter):
31+
pass
32+
33+
3034
def main(argv: list[str] | None = None) -> int:
3135
args_list = list(sys.argv[1:] if argv is None else argv)
3236
try:
@@ -72,7 +76,24 @@ def _handle_query(argv: list[str]) -> int:
7276

7377

7478
def _handle_config(argv: list[str]) -> int:
75-
parser = argparse.ArgumentParser(prog=f"{PROGRAM_NAME} config")
79+
parser = argparse.ArgumentParser(
80+
prog=f"{PROGRAM_NAME} config",
81+
description=(
82+
"Inspect or update sql-agent-cli configuration.\n\n"
83+
"Most query runs should not start here. The normal workflow is:\n"
84+
f" {PROGRAM_NAME} \"SELECT ...\"\n\n"
85+
"Use config commands when you need to inspect targets, change the default\n"
86+
"target, or bootstrap native auth files."
87+
),
88+
epilog=(
89+
"Examples:\n"
90+
f" {PROGRAM_NAME} config show\n"
91+
f" {PROGRAM_NAME} config set-default-target dev\n"
92+
f" {PROGRAM_NAME} config add-target reporting --engine postgres --host db.example.com --database app --user reader\n"
93+
f" {PROGRAM_NAME} config init-native-auth --engine postgres --target reporting"
94+
),
95+
formatter_class=HelpFormatter,
96+
)
7697
subparsers = parser.add_subparsers(dest="command")
7798

7899
show_parser = subparsers.add_parser("show")
@@ -136,7 +157,15 @@ def _handle_config(argv: list[str]) -> int:
136157

137158

138159
def _handle_targets(argv: list[str]) -> int:
139-
parser = argparse.ArgumentParser(prog=f"{PROGRAM_NAME} targets")
160+
parser = argparse.ArgumentParser(
161+
prog=f"{PROGRAM_NAME} targets",
162+
description=(
163+
"List configured targets and show which one is the default.\n\n"
164+
"This is a quick inspection command. Query execution still happens through:\n"
165+
f" {PROGRAM_NAME} \"SELECT ...\""
166+
),
167+
formatter_class=HelpFormatter,
168+
)
140169
parser.add_argument("--format", choices=ADMIN_FORMAT_CHOICES, default="text")
141170
args = parser.parse_args(argv)
142171
config = load_config()
@@ -191,25 +220,108 @@ def _add_target_arguments(parser: argparse.ArgumentParser) -> None:
191220

192221

193222
def _build_query_parser() -> argparse.ArgumentParser:
194-
parser = argparse.ArgumentParser(prog=PROGRAM_NAME)
195-
parser.add_argument("--target")
196-
parser.add_argument("--engine", choices=ENGINE_CHOICES)
197-
parser.add_argument("--host")
198-
parser.add_argument("--port", type=int)
199-
parser.add_argument("--database")
200-
parser.add_argument("--user")
201-
parser.add_argument("--path")
202-
parser.add_argument("--format", choices=FORMAT_CHOICES)
203-
parser.add_argument("--max-rows", type=int)
204-
parser.add_argument("--connect-timeout-seconds", type=int)
205-
parser.add_argument("--query-timeout-seconds", type=int)
206-
parser.add_argument("--ssl-mode", choices=SSL_MODE_CHOICES)
207-
parser.add_argument("--insecure", action="store_const", const="preferred")
208-
parser.add_argument("--password-stdin", action="store_true")
209-
parser.add_argument("--prompt-password", action="store_true")
210-
parser.add_argument("--query")
211-
parser.add_argument("--sql-file")
212-
parser.add_argument("query_text", nargs="?")
223+
parser = argparse.ArgumentParser(
224+
prog=PROGRAM_NAME,
225+
usage=(
226+
f"{PROGRAM_NAME} \"SELECT ...\"\n"
227+
f" {PROGRAM_NAME} --target NAME \"SELECT ...\"\n"
228+
f" {PROGRAM_NAME} [connection options] \"SELECT ...\"\n"
229+
f" {PROGRAM_NAME} config --help"
230+
),
231+
description=(
232+
"Run one read-only SQL query and print deterministic output.\n\n"
233+
"Happy path:\n"
234+
f" {PROGRAM_NAME} \"SELECT ...\"\n\n"
235+
"By default, sql-agent-cli uses the configured default target/environment.\n"
236+
"If a default target is already configured, the usual invocation is to pass\n"
237+
"only the SQL query. Do not search for config files or environment details\n"
238+
"first unless the query fails or you need a non-default target."
239+
),
240+
epilog=(
241+
"Target resolution:\n"
242+
" 1. --target NAME\n"
243+
" 2. [defaults].target from ~/.sql-agent-cli/config.toml\n"
244+
" 3. An ephemeral target built from explicit connection flags\n\n"
245+
"Query sources:\n"
246+
" Provide exactly one of: positional SQL, --query, --sql-file, or stdin.\n\n"
247+
"Examples:\n"
248+
f" {PROGRAM_NAME} \"SELECT COUNT(*) AS total FROM users\"\n"
249+
f" {PROGRAM_NAME} --target reporting \"SELECT NOW()\"\n"
250+
f" {PROGRAM_NAME} --query \"SELECT id, name FROM users ORDER BY id LIMIT 10\"\n"
251+
f" {PROGRAM_NAME} --sql-file query.sql\n"
252+
f" Get-Content query.sql | {PROGRAM_NAME}\n"
253+
f" {PROGRAM_NAME} --engine sqlite --path C:\\data\\app.db \"SELECT * FROM customers LIMIT 5\"\n\n"
254+
"Admin commands:\n"
255+
f" {PROGRAM_NAME} targets\n"
256+
f" {PROGRAM_NAME} config show\n"
257+
f" {PROGRAM_NAME} config set-default-target NAME"
258+
),
259+
formatter_class=HelpFormatter,
260+
)
261+
262+
target_group = parser.add_argument_group("Target and environment")
263+
target_group.add_argument(
264+
"--target",
265+
metavar="NAME",
266+
help="Use a named target. If omitted, the configured default target is used.",
267+
)
268+
target_group.add_argument(
269+
"--engine",
270+
choices=ENGINE_CHOICES,
271+
help="Build an ephemeral one-off target. Usually unnecessary when a default target is configured.",
272+
)
273+
target_group.add_argument("--host", help="Override or define the database host for this run.")
274+
target_group.add_argument("--port", type=int, help="Override or define the database port for this run.")
275+
target_group.add_argument("--database", help="Override or define the database name for this run.")
276+
target_group.add_argument("--user", help="Override or define the database user for this run.")
277+
target_group.add_argument("--path", help="SQLite database path for this run.")
278+
279+
query_group = parser.add_argument_group("Query input")
280+
query_group.add_argument("--query", metavar="SQL", help="SQL query text.")
281+
query_group.add_argument("--sql-file", metavar="PATH", help="Read SQL query text from a file.")
282+
query_group.add_argument(
283+
"query_text",
284+
nargs="?",
285+
metavar="SQL",
286+
help="SQL query text. This is the normal happy path: sql-agent-cli \"SELECT ...\"",
287+
)
288+
289+
output_group = parser.add_argument_group("Output and guardrails")
290+
output_group.add_argument("--format", choices=FORMAT_CHOICES, help="Output format. Defaults to config, then json.")
291+
output_group.add_argument("--max-rows", type=int, help="Maximum rows to emit before truncating output.")
292+
output_group.add_argument(
293+
"--connect-timeout-seconds",
294+
type=int,
295+
help="Connection timeout override for this run.",
296+
)
297+
output_group.add_argument(
298+
"--query-timeout-seconds",
299+
type=int,
300+
help="Query timeout override for this run.",
301+
)
302+
output_group.add_argument(
303+
"--ssl-mode",
304+
choices=SSL_MODE_CHOICES,
305+
help="TLS behavior for network databases. Defaults to secure behavior.",
306+
)
307+
output_group.add_argument(
308+
"--insecure",
309+
action="store_const",
310+
const="preferred",
311+
help="Shorthand for --ssl-mode preferred.",
312+
)
313+
314+
auth_group = parser.add_argument_group("Authentication")
315+
auth_group.add_argument(
316+
"--password-stdin",
317+
action="store_true",
318+
help="Read the password from stdin. Prefer native client auth when available.",
319+
)
320+
auth_group.add_argument(
321+
"--prompt-password",
322+
action="store_true",
323+
help="Prompt interactively for a password.",
324+
)
213325
return parser
214326

215327

tests/test_sqlite_cli.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
bootstrap_package()
1515

16-
from sql_agent.cli import main
16+
from sql_agent.cli import _build_query_parser, main
1717

1818

1919
def _test_temp_dir(name: str) -> Path:
@@ -25,6 +25,14 @@ def _test_temp_dir(name: str) -> Path:
2525

2626

2727
class SqliteCliTests(unittest.TestCase):
28+
def test_help_emphasizes_default_target_happy_path(self) -> None:
29+
help_text = _build_query_parser().format_help()
30+
31+
self.assertIn('sql-agent-cli "SELECT ..."', help_text)
32+
self.assertIn("the configured default target/environment", help_text)
33+
self.assertIn("Do not search for config files or environment details first", help_text)
34+
self.assertIn("[defaults].target from ~/.sql-agent-cli/config.toml", help_text)
35+
2836
def test_sqlite_query_returns_json_payload(self) -> None:
2937
temp_dir = _test_temp_dir("sqlite-query")
3038
self.addCleanup(lambda: shutil.rmtree(temp_dir, ignore_errors=True))

0 commit comments

Comments
 (0)