@mmerterden/multi-agent-pipeline 17.0.0 → 17.1.0
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.
- package/CHANGELOG.md +32 -0
- package/README.md +49 -4
- package/README.tr.md +50 -4
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/install/templates/multi-agent-autopilot.plist.template +79 -0
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +64 -0
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +173 -0
- package/pipeline/commands/multi-agent/autopilot-status/SKILL.md +74 -0
- package/pipeline/commands/multi-agent/channels/SKILL.md +41 -12
- package/pipeline/commands/multi-agent/help/SKILL.md +41 -35
- package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +10 -9
- package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
- package/pipeline/lib/autopilot-activation.sh +117 -0
- package/pipeline/lib/autopilot-state.sh +150 -0
- package/pipeline/lib/issue-fetcher.sh +18 -1
- package/pipeline/lib/plan-todos.sh +18 -0
- package/pipeline/multi-agent-refs/channels/jira.md +80 -20
- package/pipeline/multi-agent-refs/channels/pr.md +65 -19
- package/pipeline/multi-agent-refs/cross-cli-contract.md +6 -5
- package/pipeline/multi-agent-refs/features/visual-evidence.md +19 -7
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-2-planning.md +17 -15
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-6-commit.md +1 -1
- package/pipeline/multi-agent-refs/readiness-review.md +7 -1
- package/pipeline/multi-agent-refs/rules.md +3 -11
- package/pipeline/multi-agent-refs/tracker-contract.md +32 -0
- package/pipeline/schemas/autopilot-config.schema.json +149 -0
- package/pipeline/schemas/token-budget.json +2 -2
- package/pipeline/scripts/autopilot-arming.mjs +147 -0
- package/pipeline/scripts/autopilot-intake.mjs +383 -0
- package/pipeline/scripts/autopilot-menubar.swift +361 -0
- package/pipeline/scripts/autopilot-runner.mjs +349 -0
- package/pipeline/scripts/autopilot-status.sh +212 -0
- package/pipeline/scripts/jira-search.sh +70 -0
- package/pipeline/scripts/phase-tracker.sh +134 -12
- package/pipeline/scripts/probe-evidence-capability.sh +27 -3
- package/pipeline/scripts/run-ui-tests.sh +113 -4
- package/pipeline/skills/.skill-manifest.json +16 -4
- package/pipeline/skills/shared/core/multi-agent-autopilot-off/SKILL.md +67 -0
- package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +146 -0
- package/pipeline/skills/shared/core/multi-agent-autopilot-status/SKILL.md +64 -0
- package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +62 -11
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +9 -8
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// autopilot-menubar.swift - what is running, in the menu bar, while you are elsewhere.
|
|
2
|
+
//
|
|
3
|
+
// macOS has no Live Activity for a Mac app: ActivityKit ships in the macOS SDK
|
|
4
|
+
// but every entry point is `@available(macOS, unavailable)`, and the activities
|
|
5
|
+
// that appear in the menu bar on macOS 26 are mirrored from an iPhone. The Mac
|
|
6
|
+
// equivalent of "a thing that keeps showing progress in the corner" is an
|
|
7
|
+
// NSStatusItem, which is what this is.
|
|
8
|
+
//
|
|
9
|
+
// It is deliberately the dumbest program in the tree. It reads one JSON file and
|
|
10
|
+
// draws it. No polling of git, no shelling out, no network, no writes. Every
|
|
11
|
+
// decision - what is running, how long, what is queued - is made by the runner
|
|
12
|
+
// and lands in status.json; this only renders. That keeps the one compiled
|
|
13
|
+
// artifact we ship small enough to read in a sitting, and it means a bug here
|
|
14
|
+
// can misdraw but cannot affect a run.
|
|
15
|
+
//
|
|
16
|
+
// Unbundled on purpose. A .app means signing, notarization and a release
|
|
17
|
+
// artifact; a bare executable with `.accessory` activation owns a status item
|
|
18
|
+
// just as well, stays out of the Dock and the app switcher, and is built on
|
|
19
|
+
// demand from this source so nothing binary ever enters the package.
|
|
20
|
+
//
|
|
21
|
+
// Build: swiftc -O autopilot-menubar.swift -o ~/.claude/autopilot/bin/menubar
|
|
22
|
+
// Run: menubar [--status-file <path>] [--interval <seconds>]
|
|
23
|
+
|
|
24
|
+
import AppKit
|
|
25
|
+
|
|
26
|
+
// MARK: - What the runner writes
|
|
27
|
+
|
|
28
|
+
struct Item: Decodable {
|
|
29
|
+
let id: String // PROJ-1234, or my-app#316
|
|
30
|
+
let title: String? // the ticket's own summary, trimmed by the writer
|
|
31
|
+
let depth: String? // full | short - which pipeline this item is running
|
|
32
|
+
let phase: Loose? // "3" or 3 - jq passes the queue's own shape through
|
|
33
|
+
let phaseName: String? // "Dev"
|
|
34
|
+
let phaseTotal: Loose? // 8 for Full, fewer for Short
|
|
35
|
+
let repo: String?
|
|
36
|
+
let stack: String?
|
|
37
|
+
let startedAt: Double?
|
|
38
|
+
let state: String? // running | queued | awaiting-answer | blocked
|
|
39
|
+
let url: String? // the ticket, so a row is clickable
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Something that finished and produced a link worth opening: a PR, a Jira
|
|
43
|
+
/// comment, a Confluence page. Unattended work is only useful if what it
|
|
44
|
+
/// produced is one click away - otherwise the queue runs and you still have to
|
|
45
|
+
/// go looking for the results.
|
|
46
|
+
struct Report: Decodable {
|
|
47
|
+
let id: String
|
|
48
|
+
let kind: String? // pr | jira | confluence | wiki
|
|
49
|
+
let url: String?
|
|
50
|
+
let finishedAt: Double?
|
|
51
|
+
let outcome: String? // merged | open | failed
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// A JSON value that may honestly arrive as a string or as a number.
|
|
55
|
+
///
|
|
56
|
+
/// This is not defensive programming for its own sake. `phase` is written by
|
|
57
|
+
/// `autopilot-status.sh`, which passes the queue's own value through `jq`
|
|
58
|
+
/// untouched, so it is a number when the runner wrote a number and a string when
|
|
59
|
+
/// it wrote a string - and Swift's synthesized `Decodable` throws on the
|
|
60
|
+
/// mismatch. A throw here does not lose one field: `Status` fails to decode
|
|
61
|
+
/// entirely and the indicator reads "autopilot kapalı" while four items are in
|
|
62
|
+
/// flight. That is the exact failure this file's own comment calls worse than no
|
|
63
|
+
/// list, arriving through the type system instead of through the logic.
|
|
64
|
+
struct Loose: Decodable {
|
|
65
|
+
let text: String?
|
|
66
|
+
init(from decoder: Decoder) throws {
|
|
67
|
+
let c = try decoder.singleValueContainer()
|
|
68
|
+
if c.decodeNil() { text = nil }
|
|
69
|
+
else if let s = try? c.decode(String.self) { text = s }
|
|
70
|
+
else if let i = try? c.decode(Int.self) { text = String(i) }
|
|
71
|
+
else if let d = try? c.decode(Double.self) { text = String(Int(d)) }
|
|
72
|
+
else { text = nil }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
struct Status: Decodable {
|
|
77
|
+
let on: Bool
|
|
78
|
+
let slots: Int?
|
|
79
|
+
/// Items in flight RIGHT NOW. The writer removes an item the moment it
|
|
80
|
+
/// finishes; it reappears under `reports` with whatever it produced. Nothing
|
|
81
|
+
/// finished is ever left here - a list that keeps showing a done item is
|
|
82
|
+
/// worse than no list, because you stop trusting the ones that are real.
|
|
83
|
+
let running: [Item]?
|
|
84
|
+
let queued: [Item]?
|
|
85
|
+
let awaiting: [Item]?
|
|
86
|
+
/// Recently finished, newest first, bounded by the writer to a short window
|
|
87
|
+
/// (a day). This is not a history log - it is "what happened while you were
|
|
88
|
+
/// away", and an unbounded list stops answering that.
|
|
89
|
+
let reports: [Report]?
|
|
90
|
+
let todayCount: Int?
|
|
91
|
+
let todayUsd: Double?
|
|
92
|
+
let lastTickAt: Double?
|
|
93
|
+
let power: String? // ac | battery
|
|
94
|
+
let note: String?
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// MARK: - Rendering
|
|
98
|
+
|
|
99
|
+
/// Elapsed time as the user reads it, not as a duration library formats it.
|
|
100
|
+
/// Minutes up to an hour, then hours and minutes; a run that has been going for
|
|
101
|
+
/// three days should not say "4319dk".
|
|
102
|
+
func elapsed(since epoch: Double?) -> String {
|
|
103
|
+
guard let epoch, epoch > 0 else { return "-" }
|
|
104
|
+
let s = max(0, Date().timeIntervalSince1970 - epoch)
|
|
105
|
+
let m = Int(s / 60)
|
|
106
|
+
if m < 60 { return "\(m)dk" }
|
|
107
|
+
let h = m / 60
|
|
108
|
+
if h < 24 { return "\(h)sa \(m % 60)dk" }
|
|
109
|
+
return "\(h / 24)g \(h % 24)sa"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// The one line in the corner. Short, because the menu bar is shared real estate
|
|
113
|
+
/// and a long title pushes other people's items off a laptop display.
|
|
114
|
+
func title(_ st: Status?) -> String {
|
|
115
|
+
guard let st, st.on else { return "○" }
|
|
116
|
+
let r = (st.running ?? []).filter { ($0.state ?? "running") == "running" }.count
|
|
117
|
+
let q = st.queued?.count ?? 0
|
|
118
|
+
let a = st.awaiting?.count ?? 0
|
|
119
|
+
if r == 0 && q == 0 && a == 0 { return "◌" }
|
|
120
|
+
var parts: [String] = []
|
|
121
|
+
if r > 0 { parts.append("◐ \(r)") }
|
|
122
|
+
if q > 0 { parts.append("· \(q)") }
|
|
123
|
+
if a > 0 { parts.append("? \(a)") }
|
|
124
|
+
return parts.joined(separator: " ")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
final class Controller: NSObject {
|
|
128
|
+
private lazy var item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
129
|
+
private let path: String
|
|
130
|
+
private let interval: TimeInterval
|
|
131
|
+
private var timer: Timer?
|
|
132
|
+
|
|
133
|
+
init(path: String, interval: TimeInterval, headless: Bool = false) {
|
|
134
|
+
self.path = path
|
|
135
|
+
self.interval = interval
|
|
136
|
+
super.init()
|
|
137
|
+
if headless { return }
|
|
138
|
+
item.button?.title = "○"
|
|
139
|
+
let menu = NSMenu()
|
|
140
|
+
menu.delegate = self
|
|
141
|
+
item.menu = menu
|
|
142
|
+
refresh()
|
|
143
|
+
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
144
|
+
self?.refresh()
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private func read() -> Status? {
|
|
149
|
+
guard let data = FileManager.default.contents(atPath: path) else { return nil }
|
|
150
|
+
return try? JSONDecoder().decode(Status.self, from: data)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private func refresh() {
|
|
154
|
+
let st = read()
|
|
155
|
+
item.button?.title = title(st)
|
|
156
|
+
// A tooltip carries what does not fit in the title, for a glance that
|
|
157
|
+
// does not need a click.
|
|
158
|
+
if let st, st.on {
|
|
159
|
+
let power = st.power == "battery" ? " · bataryada, uyku serbest" : ""
|
|
160
|
+
item.button?.toolTip = "multi-agent autopilot: \(st.running?.count ?? 0) koşuyor, "
|
|
161
|
+
+ "\(st.queued?.count ?? 0) sırada\(power)"
|
|
162
|
+
} else {
|
|
163
|
+
item.button?.toolTip = "multi-agent autopilot kapalı"
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// One item, as much as fits on one line, in the order you would ask:
|
|
168
|
+
/// which item, which pipeline, how far in, how long, what stack.
|
|
169
|
+
///
|
|
170
|
+
/// ◐ PROJ-1234 · Full · 3/8 Dev · 22dk · ios
|
|
171
|
+
///
|
|
172
|
+
/// The phase is a fraction rather than a name alone, because "Dev" says
|
|
173
|
+
/// nothing about how much is left and "3/8" says it without a second line.
|
|
174
|
+
private func row(_ i: Item, prefix: String) -> NSMenuItem {
|
|
175
|
+
var line = "\(prefix)\(i.id)"
|
|
176
|
+
if let d = i.depth, !d.isEmpty { line += " · \(d.prefix(1).uppercased() + d.dropFirst())" }
|
|
177
|
+
if let p = i.phase?.text, !p.isEmpty {
|
|
178
|
+
let total = i.phaseTotal?.text.map { "/\($0)" } ?? ""
|
|
179
|
+
line += " · \(p)\(total)"
|
|
180
|
+
if let n = i.phaseName, !n.isEmpty { line += " \(n)" }
|
|
181
|
+
} else if let n = i.phaseName {
|
|
182
|
+
line += " · \(n)"
|
|
183
|
+
}
|
|
184
|
+
if i.startedAt != nil { line += " · \(elapsed(since: i.startedAt))" }
|
|
185
|
+
if let s = i.stack, !s.isEmpty { line += " · \(s)" }
|
|
186
|
+
|
|
187
|
+
let m = NSMenuItem(title: line, action: nil, keyEquivalent: "")
|
|
188
|
+
// The ticket title is long and would push every row's shape around, so
|
|
189
|
+
// it lives in the tooltip where it costs nothing.
|
|
190
|
+
if let t = i.title, !t.isEmpty { m.toolTip = t }
|
|
191
|
+
if let u = i.url, let link = URL(string: u) {
|
|
192
|
+
m.action = #selector(open(_:))
|
|
193
|
+
m.target = self
|
|
194
|
+
m.representedObject = link
|
|
195
|
+
m.isEnabled = true
|
|
196
|
+
} else {
|
|
197
|
+
m.isEnabled = false
|
|
198
|
+
}
|
|
199
|
+
return m
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// Opening a URL is the only thing this program does besides draw. It cannot
|
|
203
|
+
/// start, stop or change a run - control stays in the terminal, where it is
|
|
204
|
+
/// confirmed.
|
|
205
|
+
@objc private func open(_ sender: NSMenuItem) {
|
|
206
|
+
guard let u = sender.representedObject as? URL else { return }
|
|
207
|
+
NSWorkspace.shared.open(u)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private func reportRow(_ r: Report) -> NSMenuItem {
|
|
211
|
+
var line = " \(r.id)"
|
|
212
|
+
if let k = r.kind { line += " · \(k.uppercased())" }
|
|
213
|
+
if let o = r.outcome { line += " · \(o)" }
|
|
214
|
+
if r.finishedAt != nil { line += " · \(elapsed(since: r.finishedAt)) önce" }
|
|
215
|
+
let m = NSMenuItem(title: line, action: nil, keyEquivalent: "")
|
|
216
|
+
if let u = r.url, let link = URL(string: u) {
|
|
217
|
+
m.action = #selector(open(_:))
|
|
218
|
+
m.target = self
|
|
219
|
+
m.representedObject = link
|
|
220
|
+
m.isEnabled = true
|
|
221
|
+
} else {
|
|
222
|
+
m.isEnabled = false
|
|
223
|
+
}
|
|
224
|
+
return m
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
func rebuild(_ menu: NSMenu) {
|
|
228
|
+
menu.removeAllItems()
|
|
229
|
+
guard let st = read() else {
|
|
230
|
+
menu.addItem(withTitle: "autopilot kapalı", action: nil, keyEquivalent: "").isEnabled = false
|
|
231
|
+
menu.addItem(.separator())
|
|
232
|
+
let q = NSMenuItem(title: "Göstergeyi kapat", action: #selector(quit), keyEquivalent: "q")
|
|
233
|
+
q.target = self
|
|
234
|
+
menu.addItem(q)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
if !st.on {
|
|
238
|
+
menu.addItem(withTitle: "autopilot kapalı", action: nil, keyEquivalent: "").isEnabled = false
|
|
239
|
+
} else {
|
|
240
|
+
// Fixed order, running first: the thing you would interrupt for is
|
|
241
|
+
// always in the same place.
|
|
242
|
+
//
|
|
243
|
+
// Filtered on `state` as well as on which array it is in. A finished
|
|
244
|
+
// item belongs in `reports`, not in `running`, and the writer moves
|
|
245
|
+
// it - but a renderer that trusts the array alone will pin a finished
|
|
246
|
+
// item at the top of the menu until the next write, which is exactly
|
|
247
|
+
// the "it says it is still working when it is not" failure this whole
|
|
248
|
+
// release was about. Two conditions, one of them local.
|
|
249
|
+
for i in (st.running ?? []).filter({ ($0.state ?? "running") == "running" }) {
|
|
250
|
+
menu.addItem(row(i, prefix: "◐ "))
|
|
251
|
+
}
|
|
252
|
+
if let q = st.queued, !q.isEmpty {
|
|
253
|
+
menu.addItem(.separator())
|
|
254
|
+
menu.addItem(withTitle: "Sırada (\(q.count))", action: nil, keyEquivalent: "").isEnabled = false
|
|
255
|
+
for i in q.prefix(8) { menu.addItem(row(i, prefix: "· ")) }
|
|
256
|
+
if q.count > 8 {
|
|
257
|
+
menu.addItem(withTitle: " ... \(q.count - 8) tane daha", action: nil, keyEquivalent: "").isEnabled = false
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if let a = st.awaiting, !a.isEmpty {
|
|
261
|
+
menu.addItem(.separator())
|
|
262
|
+
menu.addItem(withTitle: "Cevap bekliyor (\(a.count))", action: nil, keyEquivalent: "").isEnabled = false
|
|
263
|
+
for i in a.prefix(6) { menu.addItem(row(i, prefix: "? ")) }
|
|
264
|
+
}
|
|
265
|
+
if (st.running?.isEmpty ?? true) && (st.queued?.isEmpty ?? true) && (st.awaiting?.isEmpty ?? true) {
|
|
266
|
+
menu.addItem(withTitle: "açık, sırada iş yok", action: nil, keyEquivalent: "").isEnabled = false
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// What it produced. Without this the menu answers "is it working" and
|
|
270
|
+
// never "what did it do", which is the question you actually have after
|
|
271
|
+
// being away.
|
|
272
|
+
if let rs = st.reports, !rs.isEmpty {
|
|
273
|
+
menu.addItem(.separator())
|
|
274
|
+
menu.addItem(withTitle: "Raporlar (\(rs.count))", action: nil, keyEquivalent: "").isEnabled = false
|
|
275
|
+
for r in rs.prefix(8) { menu.addItem(reportRow(r)) }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
menu.addItem(.separator())
|
|
279
|
+
if let n = st.todayCount {
|
|
280
|
+
var line = "bugün \(n) madde"
|
|
281
|
+
if let u = st.todayUsd { line += String(format: " · $%.2f", u) }
|
|
282
|
+
menu.addItem(withTitle: line, action: nil, keyEquivalent: "").isEnabled = false
|
|
283
|
+
}
|
|
284
|
+
if let t = st.lastTickAt {
|
|
285
|
+
menu.addItem(withTitle: "son tarama \(elapsed(since: t)) önce", action: nil, keyEquivalent: "").isEnabled = false
|
|
286
|
+
}
|
|
287
|
+
if let n = st.note, !n.isEmpty {
|
|
288
|
+
menu.addItem(withTitle: n, action: nil, keyEquivalent: "").isEnabled = false
|
|
289
|
+
}
|
|
290
|
+
menu.addItem(.separator())
|
|
291
|
+
// Control lives in the terminal. This closes the INDICATOR only, and says
|
|
292
|
+
// so - a menu item that reads "Kapat" next to a queue would reasonably be
|
|
293
|
+
// taken for "stop the autopilot", and quietly stopping unattended work
|
|
294
|
+
// from a menu nobody confirmed is the wrong default.
|
|
295
|
+
menu.addItem(withTitle: "Durdurmak için: /multi-agent:autopilot-off", action: nil, keyEquivalent: "").isEnabled = false
|
|
296
|
+
let q = NSMenuItem(title: "Göstergeyi kapat", action: #selector(quit), keyEquivalent: "q")
|
|
297
|
+
q.target = self
|
|
298
|
+
menu.addItem(q)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
@objc private func quit() { NSApplication.shared.terminate(nil) }
|
|
302
|
+
|
|
303
|
+
/// The title and every menu line, as text. Built by the same rebuild() the
|
|
304
|
+
/// live menu uses, so what a gate asserts is what a user sees.
|
|
305
|
+
func dump() -> String {
|
|
306
|
+
let st = read()
|
|
307
|
+
let menu = NSMenu()
|
|
308
|
+
rebuild(menu)
|
|
309
|
+
var out = ["title: \(title(st))"]
|
|
310
|
+
for i in 0..<menu.numberOfItems {
|
|
311
|
+
let mi = menu.item(at: i)!
|
|
312
|
+
out.append(mi.isSeparatorItem ? "---" : mi.title)
|
|
313
|
+
}
|
|
314
|
+
return out.joined(separator: "\n")
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
extension Controller: NSMenuDelegate {
|
|
319
|
+
func menuWillOpen(_ menu: NSMenu) { rebuild(menu) }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// MARK: - Entry
|
|
323
|
+
|
|
324
|
+
var statusPath = (NSHomeDirectory() as NSString).appendingPathComponent(".claude/autopilot/status.json")
|
|
325
|
+
var interval: TimeInterval = 5
|
|
326
|
+
|
|
327
|
+
var dumpOnly = false
|
|
328
|
+
var args = Array(CommandLine.arguments.dropFirst())
|
|
329
|
+
while let a = args.first {
|
|
330
|
+
args.removeFirst()
|
|
331
|
+
switch a {
|
|
332
|
+
case "--status-file":
|
|
333
|
+
if let v = args.first { statusPath = v; args.removeFirst() }
|
|
334
|
+
case "--interval":
|
|
335
|
+
if let v = args.first, let d = Double(v) { interval = max(1, d); args.removeFirst() }
|
|
336
|
+
case "--dump":
|
|
337
|
+
dumpOnly = true
|
|
338
|
+
case "--help", "-h":
|
|
339
|
+
print("usage: menubar [--status-file <path>] [--interval <seconds>] [--dump]")
|
|
340
|
+
exit(0)
|
|
341
|
+
default:
|
|
342
|
+
FileHandle.standardError.write("menubar: unknown option \(a)\n".data(using: .utf8)!)
|
|
343
|
+
exit(2)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// `--dump` prints the title and every menu line, then exits. It exists so the
|
|
348
|
+
// content can be asserted by a gate: a smoke test cannot click a status item, and
|
|
349
|
+
// a gate that only checks "the binary compiled" proves nothing about what the
|
|
350
|
+
// user reads. Same code path as the live menu, so the dump cannot drift from it.
|
|
351
|
+
if dumpOnly {
|
|
352
|
+
let c = Controller(path: statusPath, interval: interval, headless: true)
|
|
353
|
+
print(c.dump())
|
|
354
|
+
exit(0)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let app = NSApplication.shared
|
|
358
|
+
app.setActivationPolicy(.accessory) // no Dock tile, no app switcher entry
|
|
359
|
+
let controller = Controller(path: statusPath, interval: interval)
|
|
360
|
+
_ = controller
|
|
361
|
+
app.run()
|