convertible-cli 0.3.0__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 (122) hide show
  1. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/CHANGELOG.md +6 -0
  2. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/PKG-INFO +5 -1
  3. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/README.md +4 -0
  4. convertible_cli-0.4.0/convertible/cli/_banner-big.txt +37 -0
  5. convertible_cli-0.4.0/convertible/cli/_banner.py +56 -0
  6. convertible_cli-0.4.0/convertible/cli/_banner.txt +23 -0
  7. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/cli/_commands/drive.py +5 -0
  8. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/cli/_commands/session.py +5 -0
  9. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/pyproject.toml +1 -1
  10. convertible_cli-0.4.0/tests/test_banner.py +145 -0
  11. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/uv.lock +1 -1
  12. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/agent-config/SKILL.md +0 -0
  13. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/agent-config/data/backend-fingerprints.yaml +0 -0
  14. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/agent-config/scripts/show.sh +0 -0
  15. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/assign-to-workforce/SKILL.md +0 -0
  16. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh +0 -0
  17. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/cicd/SKILL.md +0 -0
  18. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/cicd/scripts/_resolve-nick.sh +0 -0
  19. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/cicd/scripts/portability-lint.sh +0 -0
  20. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/cicd/scripts/pr-reply.sh +0 -0
  21. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/cicd/scripts/pr-status.sh +0 -0
  22. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/cicd/scripts/workflow.sh +0 -0
  23. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/communicate/SKILL.md +0 -0
  24. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/communicate/scripts/fetch-issues.sh +0 -0
  25. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/communicate/scripts/mesh-message.sh +0 -0
  26. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/communicate/scripts/post-comment.sh +0 -0
  27. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/communicate/scripts/post-issue.sh +0 -0
  28. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/communicate/scripts/templates/skill-new-brief.md +0 -0
  29. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/communicate/scripts/templates/skill-update-brief.md +0 -0
  30. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/doc-test-alignment/SKILL.md +0 -0
  31. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/doc-test-alignment/scripts/check.sh +0 -0
  32. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/pypi-maintainer/SKILL.md +0 -0
  33. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/pypi-maintainer/scripts/switch-source.sh +0 -0
  34. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/run-tests/SKILL.md +0 -0
  35. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/run-tests/scripts/test.sh +0 -0
  36. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/sonarclaude/SKILL.md +0 -0
  37. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/sonarclaude/scripts/sonar.sh +0 -0
  38. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/spec-to-plan/SKILL.md +0 -0
  39. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/spec-to-plan/scripts/spec-to-plan.sh +0 -0
  40. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/think/SKILL.md +0 -0
  41. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/think/scripts/think.sh +0 -0
  42. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/version-bump/SKILL.md +0 -0
  43. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills/version-bump/scripts/bump.py +0 -0
  44. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.claude/skills.local.yaml.example +0 -0
  45. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.devague/current +0 -0
  46. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.devague/current_plan +0 -0
  47. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.devague/frames/convertible-gains-an-extensibility-layer-like-clau.json +0 -0
  48. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.devague/frames/convertible-v0-ships-point-it-at-a-repo-task-and-i.json +0 -0
  49. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.devague/plans/convertible-gains-an-extensibility-layer-like-clau.json +0 -0
  50. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.devague/plans/convertible-v0-ships-point-it-at-a-repo-task-and-i.json +0 -0
  51. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.flake8 +0 -0
  52. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.github/workflows/publish.yml +0 -0
  53. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.github/workflows/tests.yml +0 -0
  54. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.gitignore +0 -0
  55. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/.markdownlint-cli2.yaml +0 -0
  56. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/CLAUDE.md +0 -0
  57. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/LICENSE +0 -0
  58. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/__init__.py +0 -0
  59. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/__main__.py +0 -0
  60. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/artifact.py +0 -0
  61. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/cli/__init__.py +0 -0
  62. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/cli/_commands/__init__.py +0 -0
  63. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/cli/_commands/cli.py +0 -0
  64. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/cli/_commands/commands.py +0 -0
  65. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/cli/_commands/doctor.py +0 -0
  66. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/cli/_commands/explain.py +0 -0
  67. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/cli/_commands/hooks.py +0 -0
  68. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/cli/_commands/learn.py +0 -0
  69. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/cli/_commands/overview.py +0 -0
  70. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/cli/_commands/wheels.py +0 -0
  71. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/cli/_commands/whoami.py +0 -0
  72. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/cli/_errors.py +0 -0
  73. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/cli/_output.py +0 -0
  74. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/commands.py +0 -0
  75. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/config.py +0 -0
  76. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/configdir.py +0 -0
  77. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/contract.py +0 -0
  78. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/engine.py +0 -0
  79. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/engines/__init__.py +0 -0
  80. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/engines/mock.py +0 -0
  81. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/engines/vllm_openai.py +0 -0
  82. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/explain/__init__.py +0 -0
  83. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/explain/catalog.py +0 -0
  84. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/handoff.py +0 -0
  85. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/hooks.py +0 -0
  86. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/loop.py +0 -0
  87. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/registry.py +0 -0
  88. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/convertible/tools.py +0 -0
  89. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/culture.yaml +0 -0
  90. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/docs/plans/2026-05-26-convertible-v0-ships-point-it-at-a-repo-task-and-i.md +0 -0
  91. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/docs/plans/2026-05-27-convertible-gains-an-extensibility-layer-like-clau.md +0 -0
  92. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/docs/skill-sources.md +0 -0
  93. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/docs/specs/2026-05-26-convertible-v0-ships-point-it-at-a-repo-task-and-i.md +0 -0
  94. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/docs/specs/2026-05-27-convertible-gains-an-extensibility-layer-like-clau.md +0 -0
  95. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/sonar-project.properties +0 -0
  96. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/__init__.py +0 -0
  97. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_artifact.py +0 -0
  98. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_boundary.py +0 -0
  99. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_cli.py +0 -0
  100. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_cli_introspection.py +0 -0
  101. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_commands.py +0 -0
  102. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_commands_cli.py +0 -0
  103. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_config.py +0 -0
  104. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_configdir.py +0 -0
  105. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_contract.py +0 -0
  106. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_drive.py +0 -0
  107. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_e2e_extensibility.py +0 -0
  108. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_e2e_mock.py +0 -0
  109. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_engine.py +0 -0
  110. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_handoff.py +0 -0
  111. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_hooks.py +0 -0
  112. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_hooks_cli.py +0 -0
  113. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_loop.py +0 -0
  114. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_mock_engine.py +0 -0
  115. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_registry.py +0 -0
  116. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_review_fixes.py +0 -0
  117. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_session.py +0 -0
  118. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_tools.py +0 -0
  119. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_vllm_live.py +0 -0
  120. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_vllm_openai.py +0 -0
  121. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_wheels.py +0 -0
  122. {convertible_cli-0.3.0 → convertible_cli-0.4.0}/tests/test_zero_deps.py +0 -0
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
5
5
  Format follows [Keep a Changelog](https://keepachangelog.com/). This project
6
6
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-05-27
9
+
10
+ ### Added
11
+
12
+ - Convertible ASCII banner on 'drive' and 'session' start (issue #15): decorative chrome shown only on an interactive TTY and suppressed in --json, so it never pollutes the stdout result stream or agent-parsed stderr.
13
+
8
14
  ## [0.3.0] - 2026-05-27
9
15
 
10
16
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: convertible-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Convertible CLI is a swappable coder-agent harness that turns different models into repo workers behind one shared task contract.
5
5
  Project-URL: Homepage, https://github.com/agentculture/convertible
6
6
  Project-URL: Issues, https://github.com/agentculture/convertible/issues
@@ -65,6 +65,10 @@ which one ran.
65
65
  - **Interactive palette** — `convertible session` opens a foreground command
66
66
  browser so operators can select templates and run ad-hoc instructions without
67
67
  leaving the shell.
68
+ - **Startup banner** — `convertible drive` and `convertible session` greet an
69
+ interactive terminal with an ASCII banner. It's decorative chrome: written to
70
+ stderr, shown only on a TTY, and suppressed under `--json`, so it never
71
+ pollutes the stdout result stream or agent-parsed output.
68
72
 
69
73
  **Not in v0** (by design): a multi-engine router/policy gearbox, an execution
70
74
  sandbox, a daemon mode, and Codex/Claude/Gemini drivers. The runtime package has
@@ -48,6 +48,10 @@ which one ran.
48
48
  - **Interactive palette** — `convertible session` opens a foreground command
49
49
  browser so operators can select templates and run ad-hoc instructions without
50
50
  leaving the shell.
51
+ - **Startup banner** — `convertible drive` and `convertible session` greet an
52
+ interactive terminal with an ASCII banner. It's decorative chrome: written to
53
+ stderr, shown only on a TTY, and suppressed under `--json`, so it never
54
+ pollutes the stdout result stream or agent-parsed output.
51
55
 
52
56
  **Not in v0** (by design): a multi-engine router/policy gearbox, an execution
53
57
  sandbox, a daemon mode, and Codex/Claude/Gemini drivers. The runtime package has
@@ -0,0 +1,37 @@
1
+ ,. ..^I+[1z}{r]>1_}}]__+___+~++~".";
2
+ "; ...;^` | _.!
3
+ l, Ii < .. !).''
4
+ n1' 1. ;--l"-` C-.~
5
+ ..^ ''}|{??-J .. ."'l-,!}m\l .i.`,'. r; '.
6
+ '. l..ij~_ ;l` .... c~?> ; | `t<t .' .
7
+ ] .+I `' ' ,^ ..:,^.. "! Il'{> ii '`. .`>1;:]. n .~^v. :l!^ `'
8
+ r ._+ "it "1`<:'c} ~: '` . 1< `> :,. ,_n+\|)`{f| _^ '
9
+ '] l'. ?I .I`:l. ..;^ l<' '. .zQx)i?tri'^ .':' .. . `.`., .Q.>U. .
10
+ v{ . . 'j\ . "<.:< { Q|<'I?< j I.*Cj!;1?[>`..,';]`( `" ;? .)".^~' ;(;
11
+ i| ..]. ^' ] . :!....Cch?> ... '. . 'c[. .' <(
12
+ ;l ^" "" |1.>i :;U'" +. .I .+.!^ l+ l .! I~
13
+ t .. . ?<.. .. `'.|i. ,I ` }! <\ ` .}. '.'f:
14
+ . ....'.n.}<" .l "~."i Y._. ^.^: `. - t'.
15
+ 0|[.,^ J. ? ` ' ^ i? . ':\. ;. ^ ."`t .
16
+ . ~':!.r . ^<>.. Y'l' ~- i . .-
17
+ .> .I1."" .I.<` ^, "".t `^+ !`' ';` .`
18
+ '.'+/. ''x.^+" [i' ^: .."` ~< fc ,.'(Iv ."_
19
+ ;:_:+!. i~. ;,^]:?...l` '^ 'i.| ^,j i .c ,. '-j .lu} `_.!!. ^.
20
+ ..i;n~'.j ^x^ .!>i]`. c.. . ...' `'.i^.. . .,w . )i '`. .'<):' ;j,"..`']^^ '. .' "
21
+ `^ ii } ;`n .,l]+:- ~+` :l .?.lQ. _` .I+ <. >i[ i!?^'|"' I ^i. ^l-;j^,.~I:. 1~?]`'Qi .~..-It^'
22
+ . .... :~' x;"u: C.. Y. . . .c, ' '. . ' .._tn:` . .` i,," ..`' `(.p' 'z
23
+ `!.[ /^lv,i <.+ .. ," +._^ < - 1. :` ]`?}!!! .^/1 .``J!t .f.. . `! ^.
24
+ . ?. If"Y.~...| .^ ^` f. ,] t . /'. .h ^' 0 .:}h?``io['. . .+<.. ._^ ' .[
25
+ ;>`,: fl)'l_'(" . .- . ]I'".^. `^`'{t ..'l . .~l` .^;(,. ) i| +'`'`_.
26
+ ^.) ^:". ` f`'^ ? ^'z-l. 1]:.. Xn" . .:^`.. .":Z:. ^"c:' , ;x^|' |m`.
27
+ <~ ` . . `!. .x`;<`?`; ].'.^?.I; (`.. <?i>lI^ . .`;"... [j: .I]: !,)I.. <. [ +;
28
+ ". .. .<,} '`(,j`..`.> n. "" l` `tO- .`<1X.)^xC`' `}( ]~ '.
29
+ . ..+: ;^f_, `c '. :~."v.J|!I` '/x^ "+b '".?"(XYX _ ``..
30
+ ...^+.`I".^.."m .x . . ^" " "\` .`: }.;
31
+ ., [^| ]^i Y.Y . " .: "^.-' ?; `. '!.'t:. ./,
32
+ , `+`- j .~;[. :' .^ ..t"^ ... "tj," ^[],
33
+ !: )<r.("~^1^ ' ,
34
+ ? `il"!~< `" [
35
+ .; r. z^
36
+ ``l 1 ".
37
+ . .
@@ -0,0 +1,56 @@
1
+ """Convertible ASCII banner shown at drive/session start (decorative chrome).
2
+
3
+ The art lives in ``_banner.txt`` next to this module so the wide, trailing-
4
+ whitespace-laden lines never have to satisfy ``black``/``flake8``. It is loaded
5
+ once (``lru_cache``) via :mod:`importlib.resources`, which resolves correctly for
6
+ both editable installs and built wheels — and uses only the standard library, so
7
+ the ``dependencies = []`` rule is preserved.
8
+
9
+ The banner is purely decorative, so :func:`emit_banner` shows it **only on an
10
+ interactive terminal** and **never in ``--json`` mode**. convertible is itself an
11
+ agent harness: agents (and CI) parse stderr for the ``error:``/``hint:`` rubric,
12
+ so the art must never prepend to machine-read output — only a human at a TTY sees
13
+ it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ from functools import lru_cache
20
+ from importlib import resources
21
+ from typing import Callable
22
+
23
+
24
+ @lru_cache(maxsize=1)
25
+ def banner() -> str:
26
+ """Return the convertible ASCII banner, loaded once from the data file."""
27
+ return resources.files(__package__).joinpath("_banner.txt").read_text(encoding="utf-8")
28
+
29
+
30
+ def _isatty() -> bool:
31
+ """Whether the diagnostics stream (stderr) is an interactive terminal.
32
+
33
+ Isolated as a module function so tests can force the interactive branch
34
+ without a real TTY (``monkeypatch.setattr`` this name).
35
+ """
36
+ return sys.stderr.isatty()
37
+
38
+
39
+ def emit_banner(emit: Callable[[str], None], *, json_mode: bool) -> None:
40
+ """Emit the banner via ``emit`` — only on an interactive TTY, never in ``--json``.
41
+
42
+ The banner is decorative, so two robustness rules apply:
43
+
44
+ * A missing/unreadable resource is swallowed — a packaging glitch must never
45
+ break a real drive (only the art is lost, the task still runs).
46
+ * Trailing newlines are stripped and each sink adds its own, so rendering is
47
+ identical whether ``emit`` always appends a newline (``print`` in
48
+ ``session``) or only when absent (``emit_diagnostic`` in ``drive``).
49
+ """
50
+ if json_mode or not _isatty():
51
+ return
52
+ try:
53
+ art = banner()
54
+ except OSError:
55
+ return
56
+ emit(art.rstrip("\n"))
@@ -0,0 +1,23 @@
1
+
2
+
3
+
4
+
5
+
6
+ ,"^,::,:::::I::::::^
7
+ ^!'` `"^,. ::`
8
+ `,;;i;,,' .!^'`",. "<," " ;,^
9
+ ^ '"^";"^```,":::','"'^`"."'.,:^,i::,"".
10
+ .I. ' I-l '",I;!ii,I.,,l;;IlIl:i!:::::,,,^^;",,'
11
+ '^ ""::``':. .`";,"```"^.. '^^'":,''.
12
+ ::,`;!"..^..'^"^ .' .."" ..`"^ ..`"`'"".
13
+ ;;l!i:' ^ '^"`. `"`.. ',^.. `` '^
14
+ :i>I!lII". .' .''^` '`^' .`". ^. :;lI.
15
+ ",l!`;"':l;,,`. .' '",'. ^...'^`'"...^l'":;Ii!!l:!';.
16
+ ^''^., '`^^`:l;"^"',!~ .^^^,""..'`^. .^l,I''^.:l!;;;i>|+,!IlI::^
17
+ `;. :>i":` "`, ',^^" "; I.'. :::"^:;iil;'":^; '"
18
+ '^"'"IlI,.:,::;:' .`;"!l:``,,"``',,,^,,:ll!,,l^
19
+ ":^:,I^:"!;:: :I>l:::"`""::,:I>!<i^ " ;.
20
+ ",;"Il;i^: ',":;"`'`'^, ><[il```'
21
+ " ;;I"l:^.^;^''lI" :^::",:,'
22
+ ,`:;,I.^ ''
23
+ `. " "
@@ -26,6 +26,7 @@ from pathlib import Path
26
26
 
27
27
  from convertible import registry
28
28
  from convertible.artifact import artifact_dir, failed_result, write
29
+ from convertible.cli._banner import emit_banner
29
30
  from convertible.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError
30
31
  from convertible.cli._output import emit_diagnostic, emit_result
31
32
  from convertible.commands import CommandError, expand_command
@@ -140,6 +141,10 @@ def execute_drive(
140
141
  def cmd_drive(args: argparse.Namespace) -> int:
141
142
  json_mode = bool(getattr(args, "json", False))
142
143
 
144
+ # Decorative startup banner — interactive TTY only, suppressed in --json so
145
+ # neither stdout (the result stream) nor agent-parsed stderr is polluted (issue #15).
146
+ emit_banner(emit_diagnostic, json_mode=json_mode)
147
+
143
148
  repo = Path(args.repo).expanduser()
144
149
  if not repo.is_dir():
145
150
  raise CliError(
@@ -24,6 +24,7 @@ import sys
24
24
  from pathlib import Path
25
25
  from typing import Callable, Iterator, Optional
26
26
 
27
+ from convertible.cli._banner import emit_banner
27
28
  from convertible.cli._commands.drive import execute_drive as _default_drive
28
29
  from convertible.cli._errors import CliError
29
30
  from convertible.commands import CommandError, discover_commands, expand_command, load_command
@@ -130,6 +131,10 @@ def run_session(
130
131
  # mode, but to stderr in --json mode so stdout carries only JSON results.
131
132
  chrome: Callable[..., None] = err if json_mode else out
132
133
 
134
+ # Decorative startup banner — interactive TTY only, suppressed in --json (issue #15).
135
+ # Printed once here (not in the loop) so it greets the session, not each prompt.
136
+ emit_banner(err, json_mode=json_mode)
137
+
133
138
  config = EngineConfig.resolve(
134
139
  base_url=getattr(args, "base_url", None),
135
140
  model=getattr(args, "model", None),
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "convertible-cli"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Convertible CLI is a swappable coder-agent harness that turns different models into repo workers behind one shared task contract."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,145 @@
1
+ """The convertible startup banner — decorative chrome on drive/session start (issue #15).
2
+
3
+ Contract under test: the banner is written to **stderr**, shown **only on an
4
+ interactive TTY**, and **suppressed in ``--json`` mode** — in both ``drive`` and
5
+ ``session``. So it never pollutes the stdout result stream nor the agent-parsed
6
+ ``error:``/``hint:`` stderr that convertible (an agent harness) emits.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ from convertible.cli import main
18
+ from convertible.cli._banner import banner, emit_banner
19
+
20
+
21
+ def _force_tty(monkeypatch: pytest.MonkeyPatch) -> None:
22
+ """Make the banner think stderr is interactive (capsys/pipes are not)."""
23
+ monkeypatch.setattr("convertible.cli._banner._isatty", lambda: True)
24
+
25
+
26
+ def _art() -> str:
27
+ """The banner as ``emit_banner`` renders it (trailing newlines normalized off)."""
28
+ return banner().rstrip("\n")
29
+
30
+
31
+ class _CollectingOut:
32
+ """Fake output sink that collects all emitted lines (mirrors test_session)."""
33
+
34
+ def __init__(self) -> None:
35
+ self.lines: list[str] = []
36
+
37
+ def __call__(self, *args: object, **kwargs: object) -> None:
38
+ self.lines.append(" ".join(str(a) for a in args))
39
+
40
+ def text(self) -> str:
41
+ return "\n".join(self.lines)
42
+
43
+
44
+ def _session_args(tmp_path: Path, *, json_mode: bool) -> argparse.Namespace:
45
+ return argparse.Namespace(
46
+ repo=str(tmp_path),
47
+ engine="mock",
48
+ no_pr=True,
49
+ base="main",
50
+ base_url=None,
51
+ model=None,
52
+ api_key=None,
53
+ max_steps=None,
54
+ json=json_mode,
55
+ )
56
+
57
+
58
+ def _run_session(args: argparse.Namespace) -> tuple[_CollectingOut, _CollectingOut]:
59
+ from convertible.cli._commands.session import run_session
60
+
61
+ out, err = _CollectingOut(), _CollectingOut()
62
+ rc = run_session(args, input_fn=iter(["q"]), out=out, err=err)
63
+ assert rc == 0
64
+ return out, err
65
+
66
+
67
+ def test_banner_loads_nonempty_art() -> None:
68
+ """The data file loads and yields the multi-line ASCII art."""
69
+ art = banner()
70
+ assert art.strip(), "banner should not be empty"
71
+ assert art.count("\n") >= 5, "expected a multi-line art block"
72
+ assert banner() is art, "banner() is cached"
73
+
74
+
75
+ def test_drive_banner_on_tty(
76
+ tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
77
+ ) -> None:
78
+ _force_tty(monkeypatch)
79
+ rc = main(["drive", "do work", "--repo", str(tmp_path), "--engine", "mock", "--no-pr"])
80
+ assert rc == 0
81
+ captured = capsys.readouterr()
82
+ assert _art() in captured.err, "banner should print to stderr on an interactive drive"
83
+ assert _art() not in captured.out, "banner must never reach the stdout result stream"
84
+
85
+
86
+ def test_drive_no_banner_when_not_a_tty(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
87
+ """Without a TTY (pipes, CI, agents), stderr stays clean for the error rubric."""
88
+ rc = main(["drive", "do work", "--repo", str(tmp_path), "--engine", "mock", "--no-pr"])
89
+ assert rc == 0
90
+ captured = capsys.readouterr()
91
+ assert _art() not in captured.err
92
+ assert _art() not in captured.out
93
+
94
+
95
+ def test_drive_json_suppresses_banner(
96
+ tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
97
+ ) -> None:
98
+ _force_tty(monkeypatch) # even on a TTY, --json suppresses it
99
+ rc = main(
100
+ ["drive", "do work", "--repo", str(tmp_path), "--engine", "mock", "--no-pr", "--json"]
101
+ )
102
+ assert rc == 0
103
+ captured = capsys.readouterr()
104
+ assert _art() not in captured.err, "--json must suppress the banner entirely"
105
+ assert _art() not in captured.out
106
+ assert json.loads(captured.out)["status"] == "ok", "stdout is still pure JSON"
107
+
108
+
109
+ def test_session_banner_on_tty(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
110
+ _force_tty(monkeypatch)
111
+ out, err = _run_session(_session_args(tmp_path, json_mode=False))
112
+ assert _art() in err.text(), "banner should greet the session on stderr"
113
+ assert _art() not in out.text()
114
+
115
+
116
+ def test_session_json_suppresses_banner(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
117
+ _force_tty(monkeypatch)
118
+ out, err = _run_session(_session_args(tmp_path, json_mode=True))
119
+ assert _art() not in err.text(), "--json must suppress the banner in session too"
120
+ assert _art() not in out.text()
121
+
122
+
123
+ def test_drive_and_session_banner_render_identically(
124
+ tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
125
+ ) -> None:
126
+ """No trailing-blank-line mismatch between drive (emit_diagnostic) and session (print)."""
127
+ _force_tty(monkeypatch)
128
+ main(["drive", "do work", "--repo", str(tmp_path), "--engine", "mock", "--no-pr"])
129
+ drive_err = capsys.readouterr().err
130
+ # Drive emits the art followed by exactly one newline (no trailing blank line).
131
+ assert _art() + "\n" in drive_err
132
+ assert _art() + "\n\n" not in drive_err
133
+
134
+
135
+ def test_emit_banner_swallows_missing_resource(monkeypatch: pytest.MonkeyPatch) -> None:
136
+ """A missing/unreadable resource must never break a drive — the banner is decorative."""
137
+ _force_tty(monkeypatch)
138
+
139
+ def _boom() -> str:
140
+ raise FileNotFoundError("_banner.txt")
141
+
142
+ monkeypatch.setattr("convertible.cli._banner.banner", _boom)
143
+ emitted: list[str] = []
144
+ emit_banner(emitted.append, json_mode=False) # must not raise
145
+ assert emitted == [], "no art emitted when the resource is missing"
@@ -72,7 +72,7 @@ wheels = [
72
72
 
73
73
  [[package]]
74
74
  name = "convertible-cli"
75
- version = "0.3.0"
75
+ version = "0.4.0"
76
76
  source = { editable = "." }
77
77
 
78
78
  [package.dev-dependencies]
File without changes
File without changes