bacon-tracker 1.0.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.
data/bin/tracker-init ADDED
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "fileutils"
6
+ require "bacon_tracker"
7
+
8
+ # ── CLI options ───────────────────────────────────────────────────────────────
9
+
10
+ options = {}
11
+ OptionParser.new do |opts|
12
+ opts.banner = <<~BANNER
13
+ Usage: tracker-init [options]
14
+
15
+ Creates tracker/ and docs/decisions/ inside a project directory and
16
+ registers the project in your dashboard.md.
17
+
18
+ BANNER
19
+ opts.on("--path PATH", "Project directory (tracker/ and docs/decisions/ are created inside)") { |v| options[:path] = v }
20
+ opts.on("--namespace NS", "Story ID prefix - 2-8 letters/digits starting with a letter, e.g. APP") { |v| options[:namespace] = v }
21
+ opts.on("--title TITLE", "Project title (default: from the directory name)") { |v| options[:title] = v }
22
+ opts.on("--dashboard PATH", "Path to dashboard.md (default: ./dashboard.md)") { |v| options[:dashboard] = v }
23
+ opts.on("--command", "Also install the /tracker Claude Code command") { options[:command] = true }
24
+ opts.on("-y", "--yes", "Non-interactive: no prompts, defaults for anything not given") { options[:yes] = true }
25
+ opts.on("-h", "--help", "Show this help") { puts opts; exit }
26
+ end.parse!
27
+ OPTIONS_YES = options[:yes]
28
+
29
+ # ── Interactive prompts ───────────────────────────────────────────────────────
30
+
31
+ def prompt(label, default: nil, required: false)
32
+ # --yes means no prompts at all: take the default, or fail with the flag to
33
+ # pass - never block a script on stdin (BT-179).
34
+ if OPTIONS_YES
35
+ return default if default
36
+
37
+ warn " Error: #{label} is required - pass it as a flag with --yes."
38
+ exit 1
39
+ end
40
+
41
+ if default
42
+ print " #{label} [#{default}]: "
43
+ else
44
+ print " #{label}: "
45
+ end
46
+ $stdout.flush
47
+ val = $stdin.gets&.chomp
48
+ val = nil if val&.empty?
49
+ result = val || default
50
+ if required && (result.nil? || result.empty?)
51
+ warn " Error: #{label} is required."
52
+ exit 1
53
+ end
54
+ result
55
+ end
56
+
57
+ puts
58
+ puts " tracker-init - initialize a new tracker project"
59
+ puts " " + ("─" * 44)
60
+ puts
61
+
62
+ path = options[:path] ||
63
+ prompt("Project directory (tracker/ goes here)", required: true)
64
+ path = File.expand_path(path)
65
+
66
+ namespace = options[:namespace] ||
67
+ prompt("Namespace (2-8 letters/digits, e.g. APP)", required: true)
68
+ namespace = namespace.strip.upcase
69
+ # Validate rather than rewrite: silently turning B2B into BB or bacon-tracker
70
+ # into BACO registered a namespace the user never chose (BT-179). Digits are
71
+ # fine - the core sorts on the trailing number (BT-104).
72
+ unless namespace.match?(/\A[A-Z][A-Z0-9]{1,7}\z/)
73
+ warn " Error: namespace must be 2-8 characters, a letter followed by letters or digits (got #{namespace.inspect})."
74
+ exit 1
75
+ end
76
+
77
+ default_title = File.basename(path).gsub(/[-_]/, ' ').split.map(&:capitalize).join(' ')
78
+ title = options[:title] ||
79
+ prompt("Project title", default: default_title, required: true)
80
+
81
+ default_dashboard = File.join(Dir.pwd, 'dashboard.md')
82
+ dashboard_path = options[:dashboard] ||
83
+ prompt("dashboard.md location", default: default_dashboard)
84
+ dashboard_path = File.expand_path(dashboard_path)
85
+
86
+ tracker_root = File.join(path, 'tracker')
87
+ dashboard_dir = File.dirname(dashboard_path)
88
+
89
+ # ── Collision check (before creating anything) ────────────────────────────────
90
+ # Refuse a namespace/path already registered in dashboard.md up front, so a
91
+ # collision can't leave an orphaned, unregistered tracker whose summary then
92
+ # tells the user to run NS=<ns> commands that target a different project (BT-109).
93
+ if File.exist?(dashboard_path)
94
+ existing = File.read(dashboard_path, encoding: 'utf-8')
95
+ if existing.match?(/^namespace: #{Regexp.escape(namespace)}$/)
96
+ warn " Error: namespace #{namespace} is already registered in #{dashboard_path}."
97
+ exit 1
98
+ elsif existing.match?(/^path: (#{Regexp.escape(path)}|#{Regexp.escape(tracker_root)})$/)
99
+ warn " Error: #{path} is already registered in #{dashboard_path}."
100
+ exit 1
101
+ end
102
+ end
103
+
104
+ # ── Confirm ───────────────────────────────────────────────────────────────────
105
+
106
+ puts
107
+ puts " Ready to create:"
108
+ puts " Tracker: #{tracker_root}"
109
+ puts " Namespace: #{namespace}"
110
+ puts " Title: #{title}"
111
+ puts " Dashboard: #{dashboard_path}"
112
+ puts " /tracker: #{options[:command] ? 'install Claude command' : 'skipped (pass --command to install)'}"
113
+ puts
114
+
115
+ unless options[:yes]
116
+ print " Proceed? [Y/n]: "
117
+ $stdout.flush
118
+ answer = $stdin.gets&.chomp.to_s.strip.downcase
119
+ if answer.start_with?('n', 'q') # 'n', 'no', 'q', 'quit' all cancel
120
+ puts " Cancelled."
121
+ exit 0
122
+ end
123
+ end
124
+
125
+ # ── Directory structure ───────────────────────────────────────────────────────
126
+
127
+ puts
128
+ puts " Creating tracker structure..."
129
+
130
+ BaconTracker::STORY_DIRS.each do |type_dir, meta|
131
+ ext = meta[:ext]
132
+
133
+ BaconTracker::STAGES.each do |stage|
134
+ dir = File.join(tracker_root, type_dir, stage)
135
+ FileUtils.mkdir_p(dir)
136
+ gitkeep = File.join(dir, '.gitkeep')
137
+ File.write(gitkeep, '') unless File.exist?(gitkeep)
138
+ puts " tracker/#{type_dir}/#{stage}/"
139
+ end
140
+
141
+ template_path = File.join(tracker_root, type_dir, "_template#{ext}")
142
+ unless File.exist?(template_path)
143
+ File.write(template_path, BaconTracker::Templates.for(ext, meta[:type]))
144
+ puts " tracker/#{type_dir}/_template#{ext}"
145
+ end
146
+ end
147
+
148
+ # Every mutation takes a flock on <root>/.lock. It is machine-local state, so
149
+ # each root ignores its own rather than relying on the host repo's .gitignore.
150
+ def ignore_lock(root)
151
+ ignore = File.join(root, '.gitignore')
152
+ return if File.exist?(ignore)
153
+
154
+ File.write(ignore, ".lock\n")
155
+ puts " #{File.basename(root)}/.gitignore"
156
+ end
157
+ ignore_lock(tracker_root)
158
+
159
+ # .next-id
160
+ next_id_path = File.join(tracker_root, '.next-id')
161
+ unless File.exist?(next_id_path)
162
+ File.write(next_id_path, '1')
163
+ puts " tracker/.next-id"
164
+ end
165
+
166
+ # backlog.md
167
+ backlog_path = File.join(tracker_root, 'backlog.md')
168
+ unless File.exist?(backlog_path)
169
+ File.write(backlog_path, "# #{title} Backlog\n\n")
170
+ puts " tracker/backlog.md"
171
+ end
172
+
173
+ # ── Decisions (BT-ADR-0014) ───────────────────────────────────────────────────
174
+ # Scaffolded beside the tracker so the contract applies to something from day
175
+ # one. `.next-id` is written eagerly rather than on first use because it is what
176
+ # marks a tracked subtree (BT-ADR-0016); a lazily-created marker is unreliable.
177
+ decisions_root = File.join(path, 'docs', 'decisions')
178
+ unless Dir.exist?(decisions_root)
179
+ puts " decisions:"
180
+ BaconTracker::STATUSES.each do |status|
181
+ FileUtils.mkdir_p(File.join(decisions_root, status))
182
+ FileUtils.touch(File.join(decisions_root, status, '.gitkeep'))
183
+ puts " docs/decisions/#{status}/"
184
+ end
185
+
186
+ File.write(File.join(decisions_root, '_template.md'), BaconTracker::Templates::DECISION)
187
+ File.write(File.join(decisions_root, '.next-id'), '1')
188
+ File.write(File.join(decisions_root, 'proposed.md'), "# Decisions to make, in order\n\n")
189
+ puts " docs/decisions/_template.md"
190
+ puts " docs/decisions/.next-id"
191
+ puts " docs/decisions/proposed.md"
192
+ ignore_lock(decisions_root)
193
+ end
194
+
195
+ # ── Register in dashboard.md ──────────────────────────────────────────────────
196
+
197
+ puts
198
+ # `path:` is the project directory; tracker/ and docs/ are found inside it
199
+ # (BT-ADR-0016). Pointing it at tracker/ itself is the older form, still read.
200
+ entry = "\n## #{title}\npath: #{path}\nnamespace: #{namespace}\n"
201
+
202
+ # A namespace/path collision already aborted above, so registration is a plain
203
+ # append (or first-time create).
204
+ if File.exist?(dashboard_path)
205
+ File.open(dashboard_path, 'a') { |f| f.write(entry) }
206
+ puts " Registered in #{dashboard_path}"
207
+ else
208
+ FileUtils.mkdir_p(dashboard_dir)
209
+ File.write(dashboard_path, "# Bacon Dashboard\n#{entry}")
210
+ puts " Created #{dashboard_path}"
211
+ end
212
+
213
+ # ── Rakefile in dashboard home ────────────────────────────────────────────────
214
+
215
+ rakefile_path = File.join(dashboard_dir, 'Rakefile')
216
+ unless File.exist?(rakefile_path)
217
+ File.write(rakefile_path, <<~RUBY)
218
+ # Bacon Tracker - central Rakefile
219
+ # Run from this directory with NS=<namespace> rake story:<command>
220
+ #
221
+ # Examples:
222
+ # Quote the task: zsh (the macOS default) treats [ ] as a glob.
223
+ #
224
+ # NS=#{namespace} rake "story:feature[My feature title]"
225
+ # NS=#{namespace} rake "story:bug[Broken thing]"
226
+ # NS=#{namespace} rake "story:chore[Update deps]"
227
+ # NS=#{namespace} rake "story:commit[#{namespace}-001]"
228
+ # NS=#{namespace} rake "story:start[#{namespace}-001]"
229
+ # NS=#{namespace} rake "story:done[#{namespace}-001]"
230
+ # NS=#{namespace} rake story:next
231
+ # NS=#{namespace} rake story:lint
232
+ # NS=#{namespace} rake "decision:new[Use Postgres]"
233
+
234
+ require 'bacon_tracker'
235
+ require 'bacon_tracker/tasks'
236
+
237
+ BaconTracker::Tasks.install_for_dashboard(File.join(__dir__, '#{File.basename(dashboard_path)}'))
238
+ RUBY
239
+ puts " Created #{rakefile_path}"
240
+ end
241
+
242
+ # ── /tracker Claude command (opt-in) ──────────────────────────────────────────
243
+
244
+ if options[:command]
245
+ command_source = File.join(BaconTracker::COMMANDS_DIR, 'tracker.md')
246
+ command_target = File.join(dashboard_dir, '.claude', 'commands', 'tracker.md')
247
+ if File.exist?(command_target)
248
+ puts " #{command_target} already exists - skipping."
249
+ else
250
+ FileUtils.mkdir_p(File.dirname(command_target))
251
+ FileUtils.cp(command_source, command_target)
252
+ puts " Installed /tracker command at #{command_target}"
253
+ end
254
+ end
255
+
256
+ # ── Summary ───────────────────────────────────────────────────────────────────
257
+
258
+ puts
259
+ puts " Done! Next steps:"
260
+ puts
261
+ puts " cd #{dashboard_dir}"
262
+ puts " NS=#{namespace} rake \"story:feature[My first feature]\""
263
+ puts
264
+ puts " Or start the dashboard server:"
265
+ puts
266
+ puts " tracker-dashboard --dashboard #{dashboard_path}"
267
+ puts