spltty 0.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.
@@ -0,0 +1,407 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "erb"
5
+ require "fileutils"
6
+
7
+ module SplttyCLI
8
+ module Commands
9
+ # `spltty install` — scaffold a whole workspace in a folder: the CLI config,
10
+ # accounts/ with one entries + notes file per ledger, sources/INDEX.md, and a
11
+ # CLAUDE.md generated from the shipped template so an AI agent can drive the
12
+ # tracker from the first session.
13
+ #
14
+ # Interactive by default; every step can be skipped except the participants
15
+ # (at least one) and the default ledger (pre-filled with "expenses").
16
+ class Install < Dry::CLI::Command
17
+ desc "Set up a new expense-tracking workspace in a folder"
18
+
19
+ argument :path, required: false, desc: "Workspace directory (default: current directory)"
20
+
21
+ option :participants, aliases: ["-P"], desc: "Comma-separated participant names (e.g. Ana,Bruno)"
22
+ option :group, aliases: ["-g"], desc: "Name of the shared split group (default: Both)"
23
+ option :split, aliases: ["-s"], desc: "Group split as NAME:PCT,NAME:PCT (default: even)"
24
+ option :ledgers, aliases: ["-l"], desc: "Comma-separated ledger names (default: expenses)"
25
+ option :single, type: :boolean, default: false,
26
+ desc: "Create ledgers as single files instead of monthly"
27
+ option :git, type: :boolean, default: nil,
28
+ desc: "Initialize a git repo (--no-git to skip)"
29
+ option :force, aliases: ["-f"], type: :boolean, default: false,
30
+ desc: "Overwrite an existing workspace config"
31
+ option :yes, aliases: ["-y"], type: :boolean, default: false,
32
+ desc: "Skip prompts (non-interactive)"
33
+
34
+ example [
35
+ " # interactive, in the current directory",
36
+ "~/finances # interactive, in a new folder",
37
+ "~/finances -y -P Ana,Bruno # non-interactive, one 'expenses' ledger",
38
+ "~/finances -y -P Ana,Bruno -s Ana:60,Bruno:40 -l HOME,TRIP",
39
+ ]
40
+
41
+ def call(path: nil, **opts)
42
+ InstallRunner.new(path, opts).run
43
+ rescue Prompt::Abort, Config::Error, ArgumentError => e
44
+ warn "spltty install: #{e.message}"
45
+ exit 1
46
+ end
47
+ end
48
+
49
+ # Orchestrates the install flow. Extracted from the command so it is
50
+ # unit-testable without going through dry-cli.
51
+ class InstallRunner
52
+ DEFAULT_LEDGER = "expenses"
53
+ DEFAULT_GROUP = "Both"
54
+
55
+ def initialize(path, opts)
56
+ @root = File.expand_path(path.to_s.empty? ? Dir.pwd : path.to_s)
57
+ @opts = opts
58
+ @interactive = !opts[:yes]
59
+ end
60
+
61
+ def run
62
+ prepare_root
63
+ participants = ask_participants
64
+ group_name, group = ask_group(participants)
65
+ ledgers = ask_ledgers(participants, group_name)
66
+ methods = ask_methods(participants, group_name || participants.first)
67
+
68
+ config = write_config(participants, group_name, group, ledgers, methods)
69
+ ledgers.each { |l| write_ledger(config, l) }
70
+ write_sources
71
+ claude_path = write_claude_md(participants, group_name, group, ledgers, methods)
72
+ maybe_git_init
73
+
74
+ reconcile(config)
75
+ report(ledgers, claude_path)
76
+ end
77
+
78
+ private
79
+
80
+ # --- prompts ------------------------------------------------------------
81
+
82
+ def prepare_root
83
+ existing = Config.workspace_path(@root)
84
+ if File.exist?(existing) && !@opts[:force]
85
+ raise Config::Error,
86
+ "#{@root} is already a spltty workspace (#{existing}) — pass --force to overwrite its config"
87
+ end
88
+
89
+ FileUtils.mkdir_p(@root)
90
+ warn "Setting up a spltty workspace in #{@root}" if @interactive
91
+ end
92
+
93
+ # At least one participant is required — everything else can be skipped.
94
+ def ask_participants
95
+ list = split_list(@opts[:participants])
96
+ while list.empty?
97
+ raise Prompt::Abort, "--participants is required (e.g. -P Ana,Bruno)" unless @interactive
98
+
99
+ list = split_list(Prompt.ask("Participants (comma-separated)", required: true))
100
+ warn " (at least one participant is required)" if list.empty?
101
+ end
102
+ list
103
+ end
104
+
105
+ # The shared split group. Pointless with a single participant — skipped.
106
+ # Returns [name, {person => pct}] or [nil, nil] when there is none.
107
+ def ask_group(participants)
108
+ return [participants.first, nil] if participants.length < 2
109
+
110
+ name = @opts[:group]
111
+ name = Prompt.ask("Name for the shared split group", default: DEFAULT_GROUP) if name.nil? && @interactive
112
+ name = DEFAULT_GROUP if blank?(name)
113
+
114
+ split = @opts[:split]
115
+ default = SplttyCLI::Groups.format_split(even_split(participants))
116
+ split = Prompt.ask("Split for #{name}", default: default) if blank?(split) && @interactive
117
+ hash = blank?(split) ? even_split(participants) : SplttyCLI::Groups.parse_split(split)
118
+ SplttyCLI::Groups.validate(name, hash)
119
+ [name, hash]
120
+ end
121
+
122
+ # Even split, with the rounding remainder given to the first participant so
123
+ # the percentages always sum to 100.
124
+ def even_split(participants)
125
+ share = 100 / participants.length
126
+ hash = participants.to_h { |p| [p, share] }
127
+ hash[participants.first] += 100 - (share * participants.length)
128
+ hash
129
+ end
130
+
131
+ # One entry per ledger to create:
132
+ # { name:, type:, schema:, default_responsible:, default_currency: }
133
+ def ask_ledgers(participants, group_name)
134
+ default_responsible = group_name || participants.first
135
+ names = split_list(@opts[:ledgers])
136
+ return names.map { |n| ledger_entry(n, default_responsible) } unless names.empty?
137
+
138
+ unless @interactive
139
+ return [ledger_entry(DEFAULT_LEDGER, default_responsible, monthly: !@opts[:single])]
140
+ end
141
+
142
+ ledgers = []
143
+ loop do
144
+ name = Prompt.ask("Ledger name", default: ledgers.empty? ? DEFAULT_LEDGER : nil)
145
+ if blank?(name)
146
+ break unless ledgers.empty?
147
+
148
+ name = DEFAULT_LEDGER
149
+ end
150
+
151
+ monthly = Prompt.confirm(" Monthly (one file per month)?", default: true)
152
+ multi = Prompt.confirm(" Multi-currency (extra Orig. Value + Cur. columns)?", default: false)
153
+ currency = multi ? Prompt.ask(" Default original currency", default: "USD").upcase : nil
154
+ responsible = Prompt.ask(" Default responsible", default: default_responsible)
155
+
156
+ ledgers << ledger_entry(name, responsible, monthly: monthly, currency: currency)
157
+ break unless Prompt.confirm("Add another ledger?", default: false)
158
+ end
159
+ ledgers
160
+ end
161
+
162
+ def ledger_entry(name, responsible, monthly: nil, currency: nil)
163
+ monthly = !@opts[:single] if monthly.nil?
164
+ {
165
+ name: name,
166
+ type: monthly ? "monthly" : "single",
167
+ schema: currency ? "montreal" : "standard",
168
+ default_responsible: responsible,
169
+ default_currency: currency,
170
+ }
171
+ end
172
+
173
+ # Optional payment-method defaults (cards). Interactive only — skipped by
174
+ # default and always skippable.
175
+ def ask_methods(participants, default_responsible)
176
+ return {} unless @interactive
177
+ return {} unless Prompt.confirm("Add a payment method (card) with defaults?", default: false)
178
+
179
+ methods = {}
180
+ loop do
181
+ name = Prompt.ask(" Payment method name (e.g. \"Visa 1234\")", required: true)
182
+ cfg = {}
183
+ slug = Prompt.ask(" Short alias for -m", default: slugify(name))
184
+ cfg["slug"] = slug unless blank?(slug)
185
+ cfg["paid_by"] = Prompt.ask(" Default paid by", default: participants.first)
186
+ cfg["responsible"] = Prompt.ask(" Default responsible", default: default_responsible)
187
+ day = Prompt.ask(" Bill payment day (1-31 or 'last')", default: "last")
188
+ cfg["bill_day"] = day.to_s.strip.casecmp?("last") ? "last" : day.to_i unless blank?(day)
189
+ methods[name] = cfg
190
+
191
+ break unless Prompt.confirm("Add another payment method?", default: false)
192
+ end
193
+ methods
194
+ end
195
+
196
+ def slugify(name)
197
+ name.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
198
+ end
199
+
200
+ # --- writing ------------------------------------------------------------
201
+
202
+ def write_config(participants, group_name, group, ledgers, methods)
203
+ path = Config.workspace_path(@root)
204
+ FileUtils.mkdir_p(File.dirname(path))
205
+
206
+ config = Config.load(path)
207
+ config.data["accounts_dir"] = "../accounts"
208
+ config.data["default_ledger"] = ledgers.first[:name]
209
+ config.data["date_format"] = "%Y-%m-%d"
210
+ config.data["participants"] = participants
211
+ # Merge, never replace: on a --force re-install the cards the user
212
+ # configured with `methods add` must survive. Same additive treatment
213
+ # groups and ledger entries already get below.
214
+ config.payment_methods.merge!(methods)
215
+ config.groups[group_name] = group if group
216
+ ledgers.each do |l|
217
+ entry = { "type" => l[:type], "schema" => l[:schema], "default_responsible" => l[:default_responsible] }
218
+ l[:type] == "monthly" ? entry["dir"] = l[:name] : entry["file"] = "#{l[:name]}.ledger.md"
219
+ entry["notes"] = notes_name(l)
220
+ entry["default_currency"] = l[:default_currency] if l[:default_currency]
221
+ entry["groups"] = { group_name => group } if group
222
+ config.ledgers[l[:name]] = entry
223
+ end
224
+ config.save
225
+ config
226
+ end
227
+
228
+ # Entries file (via Ledger.scaffold) + notes file carrying the ledger's
229
+ # config in its YAML header — the header is the source of truth NotesSync
230
+ # reconciles against config.json.
231
+ def write_ledger(config, ledger)
232
+ entry = config.ledgers[ledger[:name]]
233
+ today = Date.today
234
+ entries_path = Ledger.target_path(config, ledger[:name], entry, today)
235
+ unless File.exist?(entries_path)
236
+ Ledger.scaffold(entries_path,
237
+ Ledger.title(ledger[:name], entry, today),
238
+ Ledger.notes_pointer(ledger[:name], entry),
239
+ Ledger.schema(entry))
240
+ end
241
+
242
+ notes_path = Notes.path_for(config.accounts_dir, ledger[:name], entry)
243
+ return if File.exist?(notes_path)
244
+
245
+ frontmatter = { "spltty" => header_for(entry, ledger) }
246
+ body = render("notes.md.erb",
247
+ name: ledger[:name],
248
+ entries: entries_label(ledger),
249
+ default_responsible: ledger[:default_responsible],
250
+ group_line: group_line(entry),
251
+ multi_currency: ledger[:schema] == "montreal",
252
+ default_currency: ledger[:default_currency])
253
+ FileUtils.mkdir_p(File.dirname(notes_path))
254
+ Notes.write(notes_path, frontmatter, body)
255
+ end
256
+
257
+ # The `spltty:` sub-map written into a ledger's notes header. Only the
258
+ # fields NotesSync manages, and only when set.
259
+ #
260
+ # `title` is deliberately omitted: Ledger.title treats it as a *format
261
+ # template* (`%{name} — %{month_name} %{year}`), so writing a rendered
262
+ # title here would freeze every future month file to this month's name.
263
+ def header_for(entry, ledger)
264
+ header = { "default_responsible" => ledger[:default_responsible] }
265
+ header["default_currency"] = ledger[:default_currency] if ledger[:default_currency]
266
+ header["groups"] = entry["groups"] if entry["groups"]
267
+ header
268
+ end
269
+
270
+ # How the notes file refers to its entries: a monthly ledger has many, so
271
+ # it gets a description rather than a link to one particular month.
272
+ def entries_label(ledger)
273
+ return "the `YYYY-MM.md` files in this folder" if ledger[:type] == "monthly"
274
+
275
+ file = "#{ledger[:name]}.ledger.md"
276
+ "[`#{file}`](#{file})"
277
+ end
278
+
279
+ def group_line(entry)
280
+ groups = entry["groups"]
281
+ return nil unless groups.is_a?(Hash) && !groups.empty?
282
+
283
+ groups.map { |name, hash| "`#{name}` = #{SplttyCLI::Groups.format(hash)}" }.join(", ")
284
+ end
285
+
286
+ def notes_name(ledger)
287
+ ledger[:type] == "monthly" ? "notes.md" : "#{ledger[:name]}.notes.md"
288
+ end
289
+
290
+ def write_sources
291
+ path = File.join(@root, "sources", "INDEX.md")
292
+ return if File.exist?(path)
293
+
294
+ FileUtils.mkdir_p(File.dirname(path))
295
+ File.write(path, File.read(File.join(SplttyCLI::TEMPLATES_DIR, "sources-INDEX.md")))
296
+ end
297
+
298
+ # Render the workspace guide. An existing CLAUDE.md is never clobbered —
299
+ # the generated one lands next to it as CLAUDE.spltty.md instead.
300
+ def write_claude_md(participants, group_name, group, ledgers, methods)
301
+ groups = group ? { group_name => group } : {}
302
+ content = render("CLAUDE.md.erb",
303
+ participants: participants,
304
+ participants_sentence: sentence(participants),
305
+ groups: groups,
306
+ default_responsible: group_name || participants.first,
307
+ example_split: SplttyCLI::Groups.format_split(even_split(participants)),
308
+ default_ledger: ledgers.first[:name],
309
+ methods: methods,
310
+ ledgers: ledgers.map { |l| claude_ledger_row(l) })
311
+
312
+ path = File.join(@root, "CLAUDE.md")
313
+ path = File.join(@root, "CLAUDE.spltty.md") if File.exist?(path)
314
+ File.write(path, content)
315
+
316
+ skill = File.join(@root, ".claude", "skills", "ingest", "SKILL.md")
317
+ unless File.exist?(skill)
318
+ FileUtils.mkdir_p(File.dirname(skill))
319
+ File.write(skill, File.read(File.join(SplttyCLI::TEMPLATES_DIR, "skills", "ingest", "SKILL.md")))
320
+ end
321
+ path
322
+ end
323
+
324
+ def claude_ledger_row(ledger)
325
+ monthly = ledger[:type] == "monthly"
326
+ ledger.merge(
327
+ entries: monthly ? "accounts/#{ledger[:name]}/YYYY-MM.md" : "accounts/#{ledger[:name]}.ledger.md",
328
+ notes: monthly ? "accounts/#{ledger[:name]}/notes.md" : "accounts/#{ledger[:name]}.notes.md"
329
+ )
330
+ end
331
+
332
+ def maybe_git_init
333
+ return if File.exist?(File.join(@root, ".git"))
334
+
335
+ wanted = @opts[:git]
336
+ if wanted.nil?
337
+ wanted = @interactive && Prompt.confirm("Initialize a git repository here?", default: true)
338
+ end
339
+ return unless wanted
340
+
341
+ gitignore = File.join(@root, ".gitignore")
342
+ unless File.exist?(gitignore)
343
+ File.write(gitignore, File.read(File.join(SplttyCLI::TEMPLATES_DIR, "gitignore")))
344
+ end
345
+ system("git", "init", "--quiet", @root) || warn("spltty install: git init failed — skipping")
346
+ end
347
+
348
+ # --- finishing ----------------------------------------------------------
349
+
350
+ # Reconcile what was just written on disk with the config, so the very
351
+ # first `spltty list` / `totals` run is a no-op rather than a resync.
352
+ def reconcile(config)
353
+ Discovery.sync(config)
354
+ NotesSync.run(config)
355
+ config.save
356
+ end
357
+
358
+ def report(ledgers, claude_path)
359
+ puts "Created a spltty workspace in #{@root}"
360
+ puts " #{Config::WORKSPACE_DIR}/#{Config::WORKSPACE_FILE} config (participants, groups, payment methods)"
361
+ puts " accounts/ #{ledgers.map { |l| l[:name] }.join(', ')}"
362
+ puts " sources/INDEX.md source tracker"
363
+ puts " #{File.basename(claude_path)} AI workflow guide"
364
+ puts
365
+ puts "Next:"
366
+ puts " cd #{@root}"
367
+ puts " spltty list"
368
+ puts " spltty add \"First expense\" -l #{ledgers.first[:name]} -v 10.00"
369
+ puts " spltty totals"
370
+ end
371
+
372
+ # --- helpers ------------------------------------------------------------
373
+
374
+ def render(template, **vars)
375
+ src = File.read(File.join(SplttyCLI::TEMPLATES_DIR, template))
376
+ scope = TemplateScope.new(vars)
377
+ ERB.new(src, trim_mode: "-").result(scope.binding_for)
378
+ end
379
+
380
+ def split_list(str)
381
+ str.to_s.split(",").map(&:strip).reject(&:empty?)
382
+ end
383
+
384
+ def sentence(list)
385
+ return list.first.to_s if list.length < 2
386
+
387
+ "#{list[0..-2].join(', ')} and #{list[-1]}"
388
+ end
389
+
390
+ def blank?(value)
391
+ value.nil? || value.to_s.strip.empty?
392
+ end
393
+ end
394
+
395
+ # Plain binding holder for ERB — each key becomes a local-ish reader method,
396
+ # so templates read `<%= participants %>` rather than `<%= vars[:participants] %>`.
397
+ class TemplateScope
398
+ def initialize(vars)
399
+ vars.each { |k, v| define_singleton_method(k) { v } }
400
+ end
401
+
402
+ def binding_for
403
+ binding
404
+ end
405
+ end
406
+ end
407
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SplttyCLI
4
+ module Commands
5
+ # `spltty list` — run discovery and list the known ledgers.
6
+ class List < Dry::CLI::Command
7
+ desc "List ledgers discovered under the accounts directory"
8
+
9
+ option :config, aliases: ["-C"], desc: "Path to config.json (default: cli/config.json)"
10
+ option :accounts_dir, aliases: ["-A"], desc: "Override the accounts directory"
11
+
12
+ def call(**opts)
13
+ config = SplttyCLI.load_config(opts)
14
+ result = Discovery.sync(config)
15
+ notes = NotesSync.run(config)
16
+ config.save if result[:changed] || notes.values.any? { |v| !v.empty? }
17
+
18
+ if config.ledgers.empty?
19
+ puts "No ledgers found under #{config.accounts_dir}"
20
+ return
21
+ end
22
+
23
+ width = config.ledgers.keys.map(&:length).max
24
+ puts "Ledgers (accounts dir: #{config.accounts_dir}):"
25
+ config.ledgers.each do |name, cfg|
26
+ location = cfg["dir"] ? "#{cfg['dir']}/" : cfg["file"]
27
+ flags = cfg["missing"] ? " [MISSING ON DISK]" : ""
28
+ puts format(" %-#{width}s %-8s %-9s %s%s",
29
+ name, cfg["type"], cfg["schema"], location, flags)
30
+ end
31
+ rescue Config::Error => e
32
+ warn "spltty list: #{e.message}"
33
+ exit 1
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SplttyCLI
4
+ module Commands
5
+ # `spltty methods` — list configured payment methods and their defaults.
6
+ class Methods < Dry::CLI::Command
7
+ desc "List configured payment methods and their defaults"
8
+
9
+ option :config, aliases: ["-C"], desc: "Path to config.json (default: cli/config.json)"
10
+
11
+ def call(**opts)
12
+ config = SplttyCLI.load_config(opts)
13
+ methods = config.payment_methods
14
+
15
+ if methods.empty?
16
+ puts "No payment methods configured in #{config.path}"
17
+ return
18
+ end
19
+
20
+ width = methods.keys.map(&:length).max
21
+ slug_width = methods.values.map { |c| (c["slug"] || "").length }.max
22
+ puts "Payment methods (from #{config.path}):"
23
+ methods.each do |name, cfg|
24
+ puts format(" %-#{width}s [%-#{slug_width}s] paid_by=%-7s responsible=%-16s bill_day=%s",
25
+ name, cfg["slug"], cfg["paid_by"], cfg["responsible"], cfg["bill_day"])
26
+ end
27
+ rescue Config::Error => e
28
+ warn "spltty methods: #{e.message}"
29
+ exit 1
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SplttyCLI
6
+ module Commands
7
+ # `spltty methods add` — add or update a payment method in the config. Only
8
+ # the properties you pass are set; existing ones are preserved.
9
+ class MethodsAdd < Dry::CLI::Command
10
+ desc "Add or update a payment method in the config"
11
+
12
+ argument :name, required: true, desc: "Payment method name / key (e.g. \"Mastercard 3278\")"
13
+
14
+ option :slug, aliases: ["-s"], desc: "Short alias usable with -m (e.g. mc-3278)"
15
+ option :paid_by, aliases: ["-p"], desc: "Default Paid By (Thiago/Camila/...)"
16
+ option :responsible, aliases: ["-r"], desc: "Default Responsible (Thiago/Camila/Both/...)"
17
+ option :bill_day, aliases: ["-b"], desc: "Bill payment day: a day number (1-31) or 'last'"
18
+ option :config, aliases: ["-C"], desc: "Path to config.json (default: cli/config.json)"
19
+
20
+ example [
21
+ %("Mastercard 3278" -s mc-3278 -p Camila -r Camila -b last),
22
+ %("Nubank" -s nu -p Thiago -r Both -b 10),
23
+ ]
24
+
25
+ def call(name:, **opts)
26
+ config = SplttyCLI.load_config(opts)
27
+
28
+ existed = config.payment_methods.key?(name)
29
+ entry = (config.payment_methods[name] ||= {})
30
+ entry["slug"] = opts[:slug] unless blank?(opts[:slug])
31
+ entry["paid_by"] = opts[:paid_by] unless blank?(opts[:paid_by])
32
+ entry["responsible"] = opts[:responsible] unless blank?(opts[:responsible])
33
+ entry["bill_day"] = parse_bill_day(opts[:bill_day]) unless blank?(opts[:bill_day])
34
+
35
+ config.save
36
+ puts "#{existed ? 'Updated' : 'Added'} payment method #{name.inspect}: #{entry.to_json}"
37
+ rescue Config::Error => e
38
+ warn "spltty methods add: #{e.message}"
39
+ exit 1
40
+ end
41
+
42
+ private
43
+
44
+ def blank?(value)
45
+ value.nil? || value.to_s.strip.empty?
46
+ end
47
+
48
+ def parse_bill_day(value)
49
+ s = value.to_s.strip
50
+ return "last" if s.casecmp?("last")
51
+
52
+ Integer(s)
53
+ rescue ArgumentError
54
+ raise Config::Error, "invalid --bill-day #{value.inspect} (use a day number 1-31 or 'last')"
55
+ end
56
+ end
57
+ end
58
+ end