Skip to content

Commit 3aac02a

Browse files
committed
[agent] Run the agent when launched with no arguments
Double-clicking a .app passes no arguments, and after using Quit that is the only way back a person would think to try. Printing usage and exiting 2 is completely invisible from Finder: no window, no icon, no error, nothing written anywhere a user would look. So "reopen the app" meant "know it is a launchd agent and run launchctl kickstart" — which made Quit a one-way door the moment it started working. `agent` stays spelled out; the launchd plist names its role explicitly and should keep doing so. --help now exists and prints to stdout with exit 0, while an unknown role prints to stderr with exit 2. Scripts tell those apart by stream and status, and conflating them is how --help ends up looking like a failure. install-server.sh verifies with --help rather than a bare call, which would now start an agent that never exits and burn the whole timeout on every healthy install. Solves: no way to reopen the app after Quit Tests: three guards — the empty argument shares the agent branch, help and error use different streams and statuses, and the installer does not verify with a bare invocation. All three behaviours also checked against the built binary: --help exits 0, an unknown role exits 2, and no arguments runs until killed
1 parent 2eaeabf commit 3aac02a

3 files changed

Lines changed: 91 additions & 9 deletions

File tree

install-server.sh

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -534,11 +534,12 @@ fi
534534
# can wedge is worse than no verification step.
535535
# run_bounded is defined above the source guard so the tests can reach it.
536536

537-
# `tacet` with no arguments exits 2 by design, so a non-zero status here is
538-
# expected and only the OUTPUT decides. 124 is the one status that means
539-
# something different: the timeout fired and nothing can be concluded.
537+
# --help, NOT a bare invocation. `tacet` with no arguments now runs the agent
538+
# — that is what double-clicking the app has to do — so verifying with a bare
539+
# call would start a menu-bar agent and block until the timeout every single
540+
# install. --help is the only invocation guaranteed to print and exit.
540541
set +e
541-
usage_out="$(run_bounded 20 "$APP_DST/Contents/MacOS/tacet" 2>&1)"
542+
usage_out="$(run_bounded 20 "$APP_DST/Contents/MacOS/tacet" --help 2>&1)"
542543
verify_rc=$?
543544
set -e
544545

swift/Sources/tacet/main.swift

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,61 @@ import TacetCore
66
//
77
// tacet serve → stdlib HTTP server (replaces src/tacet/*.py + FastAPI/uvicorn)
88
// tacet agent → hotkey, recorder, paste (replaces client/init.lua + rec.swift)
9+
// tacet → agent, because that is what launching the app should do
910
//
1011
// Splitting the roles (rather than always running both in one process) keeps
1112
// the two-machine setup working: the desktop runs `serve`, the laptop runs
1213
// `agent`, one signed binary for both.
1314
let args = CommandLine.arguments
14-
guard args.count >= 2 else {
15-
FileHandle.standardError.write(Data("usage: tacet <serve|agent>\n".utf8))
16-
exit(2)
15+
16+
func writeUsage(to handle: FileHandle) {
17+
handle.write(Data("""
18+
usage: tacet [serve|agent]
19+
20+
serve run the transcription server (whisper backend, HTTP)
21+
agent run the menu-bar dictation agent (hotkey, record, paste)
22+
--help this message
23+
24+
With no arguments tacet runs the agent.
25+
26+
""".utf8))
1727
}
1828

19-
switch args[1] {
29+
switch args.count >= 2 ? args[1] : "" {
2030
case "serve":
2131
do {
2232
try TacetServer().serve()
2333
} catch {
2434
FileHandle.standardError.write(Data("tacet: \(error)\n".utf8))
2535
exit(1)
2636
}
27-
case "agent":
37+
38+
// No arguments runs the agent, rather than printing usage and exiting 2.
39+
//
40+
// Double-clicking a .app passes no arguments, and after Quit that is the only
41+
// way back a person would think to try. Exiting 2 there is invisible: Finder
42+
// shows no window, no icon, no error, and nothing is written anywhere the user
43+
// would look. The old behaviour turned "reopen the app" into "know that it is
44+
// a launchd agent and run launchctl kickstart".
45+
//
46+
// `agent` stays spelled out because the launchd plist says it explicitly, and
47+
// a plist that names its role survives someone reading it a year from now.
48+
case "agent", "":
2849
let app = NSApplication.shared
2950
app.setActivationPolicy(.accessory) // LSUIElement-style: menu bar only
3051
let controller = AgentController(config: .load())
3152
controller.start()
3253
app.run()
54+
55+
// Usage goes to stdout and exits 0 when it was ASKED for, and to stderr with a
56+
// non-zero exit when it was not. Scripts check one or the other; conflating
57+
// them is how `--help` ends up looking like a failure.
58+
case "--help", "-h", "help":
59+
writeUsage(to: .standardOutput)
60+
exit(0)
61+
3362
default:
3463
FileHandle.standardError.write(Data("tacet: unknown role \(args[1])\n".utf8))
64+
writeUsage(to: .standardError)
3565
exit(2)
3666
}

tests/test_agent_menu.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,54 @@ def test_quit_is_still_wired_to_a_real_handler(self):
5757
assert "#selector(quitAction)" in code
5858
assert re.search(r"@objc[^\n]*func quitAction", code), \
5959
"quitAction must be @objc for #selector to resolve at runtime"
60+
61+
62+
MAIN = (
63+
Path(__file__).resolve().parent.parent
64+
/ "swift" / "Sources" / "tacet" / "main.swift"
65+
)
66+
67+
68+
class TestCLIContract:
69+
"""Launching the app with no arguments must run the agent.
70+
71+
Double-clicking a .app passes none, and after using Quit that is the only
72+
way back a person would think to try. The original behaviour printed usage
73+
and exit(2), which from Finder is completely invisible: no window, no icon,
74+
no error, nothing written anywhere a user would look. "Reopen the app"
75+
became "know it is a launchd agent and run launchctl kickstart".
76+
"""
77+
78+
def test_no_arguments_runs_the_agent(self):
79+
code = MAIN.read_text()
80+
assert 'case "agent", "":' in code, (
81+
"the empty argument must share the agent branch"
82+
)
83+
84+
def test_help_exits_zero_on_stdout_and_errors_do_not(self):
85+
# A script distinguishes "you asked for help" from "you got it wrong"
86+
# by stream and status. Conflating them makes --help look like failure.
87+
code = MAIN.read_text()
88+
help_branch = code[code.index('case "--help"'):code.index("default:")]
89+
assert "standardOutput" in help_branch
90+
assert "exit(0)" in help_branch
91+
default_branch = code[code.index("default:"):]
92+
assert "standardError" in default_branch
93+
assert "exit(2)" in default_branch
94+
95+
96+
class TestInstallerDoesNotVerifyWithABareCall:
97+
"""The installer must verify with --help, not a bare invocation.
98+
99+
A bare call now starts a menu-bar agent that never exits, so the
100+
verification step would burn its full timeout on every healthy install and
101+
then declare the binary broken.
102+
"""
103+
104+
def test_the_verification_passes_help(self):
105+
server = Path(__file__).resolve().parent.parent / "install-server.sh"
106+
code = server.read_text()
107+
assert 'run_bounded 20 "$APP_DST/Contents/MacOS/tacet" --help' in code
108+
assert 'run_bounded 20 "$APP_DST/Contents/MacOS/tacet" 2>&1' not in code, (
109+
"the bare form would launch the agent and hang"
110+
)

0 commit comments

Comments
 (0)