maxc-cli 0.3.2__tar.gz → 0.4.0__tar.gz

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.
Files changed (95) hide show
  1. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/PKG-INFO +1 -1
  2. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/setup.py +1 -1
  3. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/__init__.py +1 -1
  4. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/agent_platforms.py +5 -5
  5. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/app.py +47 -13
  6. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/backend/query.py +2 -1
  7. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/cli.py +36 -38
  8. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/help_format.py +43 -37
  9. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/skills/SKILL.md +1 -8
  10. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli.egg-info/PKG-INFO +1 -1
  11. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli.egg-info/SOURCES.txt +1 -0
  12. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_agent_platforms.py +9 -10
  13. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_agent_skill_commands.py +2 -2
  14. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_agent_skill_commands_context.py +18 -18
  15. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_cli_mock.py +37 -8
  16. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_e2e_smoke.py +7 -7
  17. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_flag_hoist.py +7 -2
  18. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_help_format.py +17 -17
  19. maxc_cli-0.4.0/tests/test_help_version_e2e.py +144 -0
  20. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_phase1_improvements.py +27 -49
  21. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/MANIFEST.in +0 -0
  22. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/README.md +0 -0
  23. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/pyproject.toml +0 -0
  24. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/scripts/pyinstaller_entry.py +0 -0
  25. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/scripts/regression_test.py +0 -0
  26. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/setup.cfg +0 -0
  27. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/__main__.py +0 -0
  28. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/_samples.py +0 -0
  29. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/audit.py +0 -0
  30. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/auth_providers.py +0 -0
  31. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/backend/__init__.py +0 -0
  32. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/backend/auth.py +0 -0
  33. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/backend/catalog.py +0 -0
  34. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/backend/data.py +0 -0
  35. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/backend/job.py +0 -0
  36. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/backend/meta.py +0 -0
  37. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/backend/odps.py +0 -0
  38. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/cache.py +0 -0
  39. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/catalog_bootstrap.py +0 -0
  40. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/config.py +0 -0
  41. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/exceptions.py +0 -0
  42. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/helpers.py +0 -0
  43. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/masking.py +0 -0
  44. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/models.py +0 -0
  45. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/output.py +0 -0
  46. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/setting_parser.py +0 -0
  47. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/skills/agents/openai.yaml +0 -0
  48. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/skills/references/bootstrap-auth.md +0 -0
  49. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/skills/references/bootstrap-flow.md +0 -0
  50. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/skills/references/command-patterns.md +0 -0
  51. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/skills/references/json-output-format.md +0 -0
  52. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/skills/references/maxcompute-select-guide.md +0 -0
  53. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/skills/references/maxcompute-sql-notes.md +0 -0
  54. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/skills/references/migrate-from-odpscmd.md +0 -0
  55. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/skills/references/partition-guide.md +0 -0
  56. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/skills/references/red-lines.md +0 -0
  57. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/skills/references/setup-install.md +0 -0
  58. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/skills/references/sql-common-errors.md +0 -0
  59. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/skills/references/sql-query-patterns.md +0 -0
  60. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/skills/references/text2sql-principles.md +0 -0
  61. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/store.py +0 -0
  62. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli/utils.py +0 -0
  63. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli.egg-info/dependency_links.txt +0 -0
  64. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli.egg-info/entry_points.txt +0 -0
  65. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli.egg-info/requires.txt +0 -0
  66. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/src/maxc_cli.egg-info/top_level.txt +0 -0
  67. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_agent_hints_and_cli.py +0 -0
  68. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_backend_data.py +0 -0
  69. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_backend_data_serialization.py +0 -0
  70. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_backend_meta.py +0 -0
  71. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_build_release_script.py +0 -0
  72. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_cache.py +0 -0
  73. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_catalog.py +0 -0
  74. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_catalog_bootstrap.py +0 -0
  75. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_cli_arg_validation.py +0 -0
  76. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_cli_query_parse_and_sanitize.py +0 -0
  77. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_compat.py +0 -0
  78. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_envelope_shape.py +0 -0
  79. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_error_self_correction.py +0 -0
  80. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_error_translation.py +0 -0
  81. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_exit_codes.py +0 -0
  82. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_external_auth.py +0 -0
  83. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_helpers.py +0 -0
  84. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_helpers_csv.py +0 -0
  85. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_integration.py +0 -0
  86. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_integration_real.py +0 -0
  87. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_job_improvements.py +0 -0
  88. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_masking.py +0 -0
  89. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_meta_schema_and_partition_cols.py +0 -0
  90. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_pyinstaller_bundle.py +0 -0
  91. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_query_auto_promote.py +0 -0
  92. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_query_result_csv_fallback.py +0 -0
  93. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_setting_parser.py +0 -0
  94. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_skill_cli_consistency.py +0 -0
  95. {maxc_cli-0.3.2 → maxc_cli-0.4.0}/tests/test_skill_renderer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxc-cli
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Agent-native MaxCompute CLI for external coding agents
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.9
@@ -9,7 +9,7 @@ README = ROOT / "README.md"
9
9
 
10
10
  setup(
11
11
  name="maxc-cli",
12
- version="0.3.2",
12
+ version="0.4.0",
13
13
  description="Agent-native MaxCompute CLI for external coding agents",
14
14
  long_description=README.read_text(encoding="utf-8"),
15
15
  long_description_content_type="text/markdown",
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.3.2"
5
+ __version__ = "0.4.0"
@@ -74,7 +74,7 @@ INVOCATIONS: dict[str, dict[str, str]] = {
74
74
 
75
75
 
76
76
  def _claude_root() -> Path:
77
- return Path.home() / ".claude" / "plugins" / "maxc-cli"
77
+ return Path.home() / ".claude" / "skills" / "maxc-cli"
78
78
 
79
79
 
80
80
  def _codex_root() -> Path:
@@ -83,12 +83,12 @@ def _codex_root() -> Path:
83
83
  return (
84
84
  Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex")))
85
85
  / "skills"
86
- / "maxcompute-cli-guidance"
86
+ / "maxc-cli"
87
87
  )
88
88
 
89
89
 
90
90
  def _simple_root(dotdir: str) -> Path:
91
- return Path.home() / dotdir / "skills" / "maxcompute-cli-guidance"
91
+ return Path.home() / dotdir / "skills" / "maxc-cli"
92
92
 
93
93
 
94
94
  def render_claude_plugin(install_dir: Path, cli: str, cli_module: str) -> None:
@@ -117,8 +117,8 @@ def _build_registry() -> tuple[Platform, ...]:
117
117
  name="claude-code",
118
118
  install_root=_claude_root(),
119
119
  skill_subpath=None,
120
- extra_files=(ExtraFile(".claude-plugin/plugin.json", "render_claude_plugin"),),
121
- next_step_hint="Run /reload-plugins in Claude Code to activate",
120
+ extra_files=(),
121
+ next_step_hint="Restart Claude Code or run /reload-plugins to activate",
122
122
  ),
123
123
  Platform(name="cursor", install_root=_simple_root(".cursor"),
124
124
  next_step_hint="Restart Cursor to activate"),
@@ -2161,7 +2161,13 @@ class MaxCApp:
2161
2161
  self.log("meta.list-schemas", envelope.status, envelope.metadata)
2162
2162
  return envelope
2163
2163
 
2164
- def session_set(self, project: 'str | None' = None, schema: 'str | None' = None) -> 'Envelope':
2164
+ def session_set(
2165
+ self,
2166
+ project: 'str | None' = None,
2167
+ schema: 'str | None' = None,
2168
+ *,
2169
+ target_config_path: 'Path | None' = None,
2170
+ ) -> 'Envelope':
2165
2171
  """Set default project and/or schema by writing to ~/.maxc/config.yaml.
2166
2172
 
2167
2173
  Mirrors `gcloud config set project` / `kubectl config use-context`: the
@@ -2169,8 +2175,12 @@ class MaxCApp:
2169
2175
  (e.g., ./.maxc/config.yaml) shadows the value, a warning is emitted but
2170
2176
  the write still happens — the in-memory value is updated for the current
2171
2177
  invocation.
2178
+
2179
+ When ``target_config_path`` is given (i.e. the user passed ``--config``),
2180
+ the write goes to that file instead, so a subsequent ``session show
2181
+ --config <same>`` round-trips correctly.
2172
2182
  """
2173
- target_path = default_global_config_path()
2183
+ target_path = target_config_path or default_global_config_path()
2174
2184
  config_payload = load_config_mapping(target_path) if target_path.exists() else {}
2175
2185
 
2176
2186
  changes: list[str] = []
@@ -2246,13 +2256,18 @@ class MaxCApp:
2246
2256
  self.log("session.set", envelope.status, {"changes": changes})
2247
2257
  return envelope
2248
2258
 
2249
- def session_unset(self) -> 'Envelope':
2259
+ def session_unset(
2260
+ self, *, target_config_path: 'Path | None' = None
2261
+ ) -> 'Envelope':
2250
2262
  """Remove default_project / default_schema from ~/.maxc/config.yaml.
2251
2263
 
2252
2264
  Project-level config files in the working directory are NOT modified, since
2253
2265
  they may be checked into version control. Edit those by hand if needed.
2266
+
2267
+ When ``target_config_path`` is given (``--config``), unset operates on
2268
+ that file instead of the global one.
2254
2269
  """
2255
- target_path = default_global_config_path()
2270
+ target_path = target_config_path or default_global_config_path()
2256
2271
  cleared: list[str] = []
2257
2272
 
2258
2273
  if target_path.exists():
@@ -2284,9 +2299,16 @@ class MaxCApp:
2284
2299
  self.log("session.unset", envelope.status, {})
2285
2300
  return envelope
2286
2301
 
2287
- def session_show(self) -> 'Envelope':
2288
- """Show current session settings with source information."""
2289
- config_path = default_global_config_path()
2302
+ def session_show(
2303
+ self, *, target_config_path: 'Path | None' = None
2304
+ ) -> 'Envelope':
2305
+ """Show current session settings with source information.
2306
+
2307
+ When ``target_config_path`` is given (``--config``), the reported
2308
+ ``config_path`` reflects that file, matching what ``session set
2309
+ --config <same>`` would write to.
2310
+ """
2311
+ config_path = target_config_path or default_global_config_path()
2290
2312
 
2291
2313
  env_project = os.environ.get("MAXCOMPUTE_PROJECT") or os.environ.get("ODPS_PROJECT")
2292
2314
  has_explicit_auth_provider = bool(self.config.auth.provider)
@@ -3469,6 +3491,16 @@ class MaxCApp:
3469
3491
 
3470
3492
  return sorted(files_copied)
3471
3493
 
3494
+ _LEGACY_SKILL_DIRS = ("maxcompute-cli-guidance", "use-maxc-cli")
3495
+
3496
+ def _cleanup_legacy_skill_dir(self, target: 'Path') -> None:
3497
+ """Remove legacy skill directories that have been superseded by the new path."""
3498
+ import shutil
3499
+ for old_name in self._LEGACY_SKILL_DIRS:
3500
+ old_dir = target.parent / old_name
3501
+ if old_dir.is_dir() and (old_dir / ".maxc-skill-version").is_file():
3502
+ shutil.rmtree(str(old_dir))
3503
+
3472
3504
  def skill_install(
3473
3505
  self,
3474
3506
  *,
@@ -3478,14 +3510,16 @@ class MaxCApp:
3478
3510
  force: 'bool' = False,
3479
3511
  ) -> 'Envelope':
3480
3512
  from . import agent_platforms
3481
- if invocation not in agent_platforms.INVOCATIONS:
3482
- raise ValidationError(
3483
- f"Unsupported invocation: {invocation}. "
3484
- f"Supported: {', '.join(agent_platforms.INVOCATIONS)}"
3485
- )
3513
+ # invocation is now the literal cli name (e.g. "maxc", "aliyun maxc")
3514
+ # For backwards compat, also accept legacy key "aliyun-maxc"
3515
+ if invocation in agent_platforms.INVOCATIONS:
3516
+ invocation_map = agent_platforms.INVOCATIONS[invocation]
3517
+ else:
3518
+ invocation_map = {"cli": invocation, "cli_module": invocation}
3486
3519
  platform_spec, target = self._resolve_skill_target(platform, dir_override)
3487
- invocation_map = agent_platforms.INVOCATIONS[invocation]
3488
3520
  skills_src = self._locate_skills_source()
3521
+ if dir_override is None:
3522
+ self._cleanup_legacy_skill_dir(target)
3489
3523
  version_marker = f"{__version__}+{invocation}"
3490
3524
  marker_path = target / ".maxc-skill-version"
3491
3525
  if not force and marker_path.is_file() and marker_path.read_text().strip() == version_marker:
@@ -356,13 +356,14 @@ class QueryMixin:
356
356
 
357
357
  actual_sql, hints, priority = _parse_sql_with_hints(sql, force=force)
358
358
  priority_kwargs = {"priority": priority} if priority is not None else {}
359
+ idem_kwargs = {"unique_identifier_id": idempotency_key} if idempotency_key is not None else {}
359
360
 
360
361
  try:
361
362
  instance = self.client.run_sql(
362
363
  actual_sql,
363
364
  project=project,
364
365
  hints=hints,
365
- unique_identifier_id=idempotency_key,
366
+ **idem_kwargs,
366
367
  **priority_kwargs,
367
368
  )
368
369
  except Exception as exc:
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import argparse
5
5
  import difflib
6
+ import os
6
7
  import sys
7
8
  from pathlib import Path
8
9
  from typing import Any, Sequence, TextIO
@@ -75,6 +76,9 @@ def _epilog_for(command_path: str) -> str | None:
75
76
  sample = SAMPLES.get(command_path)
76
77
  if sample is None:
77
78
  return None
79
+ cli_name = os.environ.get("MAXC_CLI_NAME", "").strip() or "maxc"
80
+ if cli_name != "maxc":
81
+ sample = sample.replace("maxc ", f"{cli_name} ")
78
82
  return "Sample:\n " + sample.replace("\n", "\n ")
79
83
 
80
84
 
@@ -82,6 +86,10 @@ def _epilog_for(command_path: str) -> str | None:
82
86
  # (e.g., --project, --limit) are NOT hoisted — they belong to specific
83
87
  # subparsers. arity = number of subsequent argv tokens consumed as a value
84
88
  # when given as `--flag value`; `--flag=value` is always a single token.
89
+ #
90
+ # -h/--help is deliberately NOT hoisted: argparse auto-adds it to every
91
+ # subparser, and hoisting would turn `maxc query -h` into top-level help
92
+ # instead of the query subcommand's help.
85
93
  _GLOBAL_FLAG_ARITY: dict[str, int] = {
86
94
  "--format": 1,
87
95
  "-f": 1,
@@ -92,8 +100,6 @@ _GLOBAL_FLAG_ARITY: dict[str, int] = {
92
100
  "--debug": 0,
93
101
  "-v": 0,
94
102
  "--version": 0,
95
- "--help": 0,
96
- "-h": 0,
97
103
  }
98
104
 
99
105
 
@@ -134,11 +140,8 @@ def _hoist_global_flags(argv: list[str]) -> list[str]:
134
140
 
135
141
 
136
142
  def _make_parser(parent_subparsers, name, command_path, **kw):
137
- """Wrap add_parser so every parser gets aliyun-style formatting + Sample epilog."""
138
- epilog = _epilog_for(command_path)
143
+ """Wrap add_parser so every parser gets aliyun-style formatting."""
139
144
  kw.setdefault("formatter_class", AliyunStyleFormatter)
140
- if epilog is not None and "epilog" not in kw:
141
- kw["epilog"] = epilog
142
145
  return parent_subparsers.add_parser(name, **kw)
143
146
 
144
147
 
@@ -159,13 +162,12 @@ def build_parser() -> argparse.ArgumentParser:
159
162
  cli_version = get_version("maxc-cli")
160
163
  except Exception:
161
164
  from maxc_cli import __version__ as cli_version
165
+ cli_name = os.environ.get("MAXC_CLI_NAME", "").strip() or "maxc"
162
166
  parser = argparse.ArgumentParser(
163
- prog="maxc",
164
- description="MaxCompute CLI — 给 Agent 调用的结构化工具层",
167
+ prog=cli_name,
165
168
  formatter_class=AliyunStyleFormatter,
166
- epilog=_epilog_for("__top__"),
167
169
  )
168
- parser.add_argument("-v", "--version", action="version", version=f"maxc {cli_version}")
170
+ parser.add_argument("-v", "--version", action="version", version=f"{cli_name} {cli_version}")
169
171
  parser.add_argument("--config", help="Explicit path to a config file")
170
172
  parser.add_argument(
171
173
  "--format",
@@ -187,7 +189,9 @@ def build_parser() -> argparse.ArgumentParser:
187
189
 
188
190
  # Top-level subparsers are NOT required: bare `maxc` is handled in run()
189
191
  # (prints help when auth is configured, redirects to `auth login` otherwise).
190
- subparsers = parser.add_subparsers(dest="command_group")
192
+ # Explicit prog= prevents argparse from calling format_help() to derive it
193
+ # (which would bake our version header into child parser prog strings).
194
+ subparsers = parser.add_subparsers(dest="command_group", prog=cli_name)
191
195
 
192
196
  query_parser = _make_parser(
193
197
  subparsers,
@@ -197,13 +201,13 @@ def build_parser() -> argparse.ArgumentParser:
197
201
  description=(
198
202
  "Run a SQL query.\n"
199
203
  "Usage:\n"
200
- " maxc query \"SELECT 1\" # default: run\n"
201
- " maxc query run \"SELECT 1\" # explicit run\n"
202
- " maxc query cost \"SELECT 1\" # estimate cost\n"
203
- " maxc query explain \"SELECT 1\" # show plan\n"
204
+ f" {cli_name} query \"SELECT 1\" # default: run\n"
205
+ f" {cli_name} query run \"SELECT 1\" # explicit run\n"
206
+ f" {cli_name} query cost \"SELECT 1\" # estimate cost\n"
207
+ f" {cli_name} query explain \"SELECT 1\" # show plan\n"
204
208
  "\n"
205
209
  "Legacy usage (--mode is deprecated):\n"
206
- " maxc query \"SELECT 1\" --mode cost"
210
+ f" {cli_name} query \"SELECT 1\" --mode cost"
207
211
  ),
208
212
  formatter_class=AliyunRawTextFormatter,
209
213
  )
@@ -1297,23 +1301,27 @@ def _handle_session_set(app: MaxCApp, args: argparse.Namespace, stdout: TextIO)
1297
1301
  """Set current project and/or schema for the session."""
1298
1302
  project = args.project
1299
1303
  schema = args.schema
1300
-
1304
+
1301
1305
  if not project and not schema:
1302
1306
  raise ValidationError("At least one of `--project` or `--schema` must be specified.")
1303
-
1304
- envelope = app.session_set(project=project, schema=schema)
1307
+
1308
+ envelope = app.session_set(
1309
+ project=project,
1310
+ schema=schema,
1311
+ target_config_path=args.requested_config_path,
1312
+ )
1305
1313
  _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
1306
1314
 
1307
1315
 
1308
1316
  def _handle_session_show(app: MaxCApp, args: argparse.Namespace, stdout: TextIO) -> None:
1309
1317
  """Show current session settings."""
1310
- envelope = app.session_show()
1318
+ envelope = app.session_show(target_config_path=args.requested_config_path)
1311
1319
  _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
1312
1320
 
1313
1321
 
1314
1322
  def _handle_session_unset(app: MaxCApp, args: argparse.Namespace, stdout: TextIO) -> None:
1315
1323
  """Clear session override."""
1316
- envelope = app.session_unset()
1324
+ envelope = app.session_unset(target_config_path=args.requested_config_path)
1317
1325
  _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
1318
1326
 
1319
1327
 
@@ -1447,23 +1455,13 @@ def _handle_agent_skill(app: MaxCApp, args: argparse.Namespace, stdout: TextIO)
1447
1455
  _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
1448
1456
 
1449
1457
 
1450
- def _detect_invocation() -> str:
1451
- """Best-effort detection of the invocation form.
1458
+ def _detect_cli_name() -> str:
1459
+ """Read MAXC_CLI_NAME env var. Defaults to 'maxc'.
1452
1460
 
1453
- When the CLI is delegated to from `aliyun maxc ...` (via the Aliyun CLI
1454
- plugin bridge), `MAXC_INVOCATION` or `ALIYUN_CLI_NAME` is typically set;
1455
- fall back to scanning argv[0] for `aliyun`. Defaults to `maxc`.
1461
+ The value is used directly as {{cli}} in SKILL templates.
1462
+ Example: MAXC_CLI_NAME='aliyun maxc' renders all commands as `aliyun maxc ...`.
1456
1463
  """
1457
- import os
1458
- explicit = os.environ.get("MAXC_INVOCATION", "").strip().lower()
1459
- if explicit in ("maxc", "aliyun-maxc"):
1460
- return explicit
1461
- if os.environ.get("ALIYUN_CLI_NAME") or os.environ.get("ALIYUN_CLI_VERSION"):
1462
- return "aliyun-maxc"
1463
- argv0 = (sys.argv[0] if sys.argv else "").lower()
1464
- if "aliyun" in os.path.basename(argv0):
1465
- return "aliyun-maxc"
1466
- return "maxc"
1464
+ return os.environ.get("MAXC_CLI_NAME", "").strip() or "maxc"
1467
1465
 
1468
1466
 
1469
1467
  def _resolve_dir_override(args: argparse.Namespace) -> Path | None:
@@ -1472,7 +1470,7 @@ def _resolve_dir_override(args: argparse.Namespace) -> Path | None:
1472
1470
 
1473
1471
 
1474
1472
  def _handle_agent_skill_install(app: MaxCApp, args: argparse.Namespace, stdout: TextIO) -> None:
1475
- invocation = args.invocation or _detect_invocation()
1473
+ invocation = args.invocation or _detect_cli_name()
1476
1474
  envelope = app.skill_install(
1477
1475
  platform=args.platform,
1478
1476
  invocation=invocation,
@@ -1483,7 +1481,7 @@ def _handle_agent_skill_install(app: MaxCApp, args: argparse.Namespace, stdout:
1483
1481
 
1484
1482
 
1485
1483
  def _handle_agent_skill_update(app: MaxCApp, args: argparse.Namespace, stdout: TextIO) -> None:
1486
- invocation = args.invocation or _detect_invocation()
1484
+ invocation = args.invocation or _detect_cli_name()
1487
1485
  # skill_update doesn't accept dir_override — `--all` iterates registry by
1488
1486
  # default install_root, single-platform mode falls through to skill_install
1489
1487
  # which would accept dir_override if we plumbed it in. Surface a validation
@@ -38,13 +38,11 @@ def strip_ansi(text: str) -> str:
38
38
 
39
39
 
40
40
  class AliyunStyleFormatter(argparse.HelpFormatter):
41
- """Aliyun-style help: ``Usage:`` / ``Flags:`` / ``Commands:`` headings.
41
+ """Aliyun CLI style: version header, Commands, Flags (long first, no-space comma)."""
42
42
 
43
- Compact synopsis and Sample-epilog handling are added in Tasks 3-4.
44
- """
43
+ _has_subparsers = False
45
44
 
46
45
  _SECTION_REMAP = {
47
- "positional arguments": "Commands",
48
46
  "options": "Flags", # Python 3.10+
49
47
  "optional arguments": "Flags", # Python 3.9
50
48
  }
@@ -52,11 +50,18 @@ class AliyunStyleFormatter(argparse.HelpFormatter):
52
50
  def start_section(self, heading):
53
51
  if heading in self._SECTION_REMAP:
54
52
  heading = self._SECTION_REMAP[heading]
53
+ # Rename "positional arguments" dynamically: "Commands" when a
54
+ # subparsers action exists, "Arguments" otherwise.
55
+ if heading == "positional arguments":
56
+ heading = "Commands" if self._has_subparsers else "Arguments"
55
57
  super().start_section(heading)
56
58
 
57
59
  def add_usage(self, usage, actions, groups, prefix=None):
58
60
  if prefix is None:
59
61
  prefix = "Usage:\n "
62
+ self._has_subparsers = any(
63
+ isinstance(a, argparse._SubParsersAction) for a in (actions or [])
64
+ )
60
65
  super().add_usage(usage, actions, groups, prefix)
61
66
 
62
67
  def _format_usage(self, usage, actions, groups, prefix):
@@ -83,49 +88,50 @@ class AliyunStyleFormatter(argparse.HelpFormatter):
83
88
  line = " ".join(parts)
84
89
  return f"{prefix}{line}\n\n"
85
90
 
86
- def _fill_text(self, text, width, indent):
87
- # Preserve sample/epilog formatting (newlines, leading indent) only for
88
- # pre-formatted multi-line text. Single-line strings fall through to
89
- # argparse's default ``textwrap.fill`` so long descriptions still wrap.
90
- if "\n" in text:
91
- return "".join(indent + line for line in text.splitlines(keepends=True))
92
- return super()._fill_text(text, width, indent)
93
-
94
- def format_help(self):
95
- text = super().format_help()
96
- if not text.endswith("\n"):
97
- text += "\n"
98
- # Skip the footer on the top-level parser (matches aliyun behavior).
99
- # Subparsers always render as ``"<prog> <group>..."`` with spaces; the
100
- # root prog is bare (no spaces), so this check is durable across
101
- # entry-point renames.
102
- if " " not in self._prog:
103
- return text
104
- # Skip the footer when argparse calls format_help() internally just to
105
- # derive a subparser's prog (``add_subparsers`` adds only the usage
106
- # section then calls format_help().strip()). The root section contains
107
- # exactly one usage item in that case; for a real --help invocation it
108
- # contains usage + the action groups (Flags, Commands, ...).
109
- if len(self._root_section.items) <= 1:
110
- return text
111
- text += f"\nUse `{self._prog} --help` for more information.\n"
112
- return text
91
+ def _format_action_invocation(self, action):
92
+ if not action.option_strings:
93
+ # Positional with choices: show dest name, not {choice1,choice2,...}
94
+ if action.choices and not isinstance(action, argparse._SubParsersAction):
95
+ return action.dest
96
+ return super()._format_action_invocation(action)
97
+ # Long option first, short after, comma with no space
98
+ opts = sorted(action.option_strings, key=lambda s: (not s.startswith('--'), s))
99
+ return ','.join(opts)
113
100
 
114
101
  def _format_action(self, action):
115
- text = super()._format_action(action)
116
102
  if isinstance(action, argparse._SubParsersAction):
103
+ # Skip the "{choices}" summary line, render only the sub-commands
104
+ parts = []
105
+ for sub_action in action._get_subactions():
106
+ parts.append(super()._format_action(sub_action))
107
+ text = ''.join(parts)
108
+ # Colorize command names
117
109
  for choice in action.choices:
118
- # Anchor to line start: argparse renders subcommand rows as
119
- # ``" <choice> <help>"`` (leading whitespace varies with
120
- # nesting). Plain ``str.replace`` would match the first
121
- # occurrence anywhere in the rendered block (e.g. inside
122
- # ``{login}`` brace lists or inside a help string).
123
110
  text = re.sub(
124
111
  rf"(?m)^(\s+){re.escape(choice)}(?=\s)",
125
112
  rf"\1{cyan(choice)}",
126
113
  text,
127
114
  count=1,
128
115
  )
116
+ return text
117
+ return super()._format_action(action)
118
+
119
+ def _fill_text(self, text, width, indent):
120
+ if "\n" in text:
121
+ return "".join(indent + line for line in text.splitlines(keepends=True))
122
+ return super()._fill_text(text, width, indent)
123
+
124
+ def format_help(self):
125
+ text = super().format_help()
126
+ if not text.endswith("\n"):
127
+ text += "\n"
128
+ # Inject version header at top for root parser's full help only.
129
+ # Guard on "Usage:" to skip injection when argparse uses format_help()
130
+ # for non-help purposes (e.g. --version action formatting).
131
+ if " " not in self._prog and "Usage:" in text:
132
+ from maxc_cli import __version__
133
+ header = f"MaxCompute CLI {__version__}\n\n"
134
+ text = header + text
129
135
  return text
130
136
 
131
137
 
@@ -1,13 +1,6 @@
1
1
  ---
2
- name: maxcompute-cli-guidance
2
+ name: maxc-cli
3
3
  description: Use when the task involves MaxCompute, ODPS, or {{cli}} — querying tables, viewing table schema, listing tables, searching metadata, executing SQL, checking partitions, sampling data, uploading or downloading CSV data, managing jobs, or generating MaxCompute SQL.
4
- description_zh: 当用户需要查询 MaxCompute/ODPS 中的表、查看表结构、列出项目中的表、搜索元数据、执行 SQL、查看分区、预览数据、上传或下载 CSV 数据、跟踪任务或生成 MaxCompute SQL 时使用。适用于提到 ODPS、MaxCompute、maxc、数据仓库表查询、text2sql 等场景。
5
- name_zh: MaxCompute数据查询
6
- category: database
7
- keywords: [MaxCompute, ODPS, maxc, 表, 查表, 查数据, SQL, 数据仓库, 元数据, 分区, odps sql, 阿里云, 上传, 下载, CSV, tunnel, text2sql, sql generation, SQL 生成]
8
- requires: MaxCompute account with AK/SK or environment variables
9
- entry_point: maxc
10
- min_cli_version: "0.2.4"
11
4
  ---
12
5
 
13
6
  # Use MaxC CLI
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxc-cli
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Agent-native MaxCompute CLI for external coding agents
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.9
@@ -76,6 +76,7 @@ tests/test_exit_codes.py
76
76
  tests/test_external_auth.py
77
77
  tests/test_flag_hoist.py
78
78
  tests/test_help_format.py
79
+ tests/test_help_version_e2e.py
79
80
  tests/test_helpers.py
80
81
  tests/test_helpers_csv.py
81
82
  tests/test_integration.py
@@ -28,10 +28,9 @@ def test_resolve_unknown_raises():
28
28
  ap.resolve("nonexistent")
29
29
 
30
30
 
31
- def test_claude_code_has_plugin_json_extra_file():
31
+ def test_claude_code_has_no_extra_files():
32
32
  claude = ap.resolve("claude-code")
33
- assert any(ef.relative_path == ".claude-plugin/plugin.json"
34
- for ef in claude.extra_files)
33
+ assert claude.extra_files == ()
35
34
 
36
35
 
37
36
  def test_install_root_matches_legacy_paths():
@@ -40,12 +39,12 @@ def test_install_root_matches_legacy_paths():
40
39
  # and the new value must be justified in the PR description (it would break
41
40
  # already-installed users).
42
41
  expected = {
43
- "claude-code": Path.home() / ".claude" / "plugins" / "maxc-cli",
44
- "cursor": Path.home() / ".cursor" / "skills" / "maxcompute-cli-guidance",
45
- "windsurf": Path.home() / ".windsurf" / "skills" / "maxcompute-cli-guidance",
46
- "qwen": Path.home() / ".qwen" / "skills" / "maxcompute-cli-guidance",
47
- "qoder": Path.home() / ".qoder" / "skills" / "maxcompute-cli-guidance",
48
- "qoderwork": Path.home() / ".qoderwork" / "skills" / "maxcompute-cli-guidance",
42
+ "claude-code": Path.home() / ".claude" / "skills" / "maxc-cli",
43
+ "cursor": Path.home() / ".cursor" / "skills" / "maxc-cli",
44
+ "windsurf": Path.home() / ".windsurf" / "skills" / "maxc-cli",
45
+ "qwen": Path.home() / ".qwen" / "skills" / "maxc-cli",
46
+ "qoder": Path.home() / ".qoder" / "skills" / "maxc-cli",
47
+ "qoderwork": Path.home() / ".qoderwork" / "skills" / "maxc-cli",
49
48
  }
50
49
  for name, expected_path in expected.items():
51
50
  assert ap.resolve(name).install_root == expected_path, name
@@ -61,7 +60,7 @@ def test_codex_install_root_respects_CODEX_HOME(monkeypatch, tmp_path):
61
60
  importlib.reload(ap)
62
61
  try:
63
62
  assert ap.resolve("codex").install_root == (
64
- tmp_path / "my-codex" / "skills" / "maxcompute-cli-guidance"
63
+ tmp_path / "my-codex" / "skills" / "maxc-cli"
65
64
  )
66
65
  finally:
67
66
  # Reload again after monkeypatch rollback so REGISTRY doesn't keep the
@@ -51,10 +51,10 @@ def test_skill_install_with_dir_override(app, tmp_path):
51
51
  assert (custom / "SKILL.md").is_file()
52
52
 
53
53
 
54
- def test_skill_install_writes_extra_files_for_claude_code(app, tmp_path):
54
+ def test_skill_install_no_extra_files_for_claude_code(app, tmp_path):
55
55
  env = app.skill_install(platform="claude-code", invocation="maxc")
56
56
  install = Path(env.data["install_path"])
57
- assert (install / ".claude-plugin" / "plugin.json").is_file()
57
+ assert not (install / ".claude-plugin").exists()
58
58
 
59
59
 
60
60
  def test_skill_install_force_overwrites(app, tmp_path):