githuh 0.3.0 → 0.4.1

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/README.md ADDED
@@ -0,0 +1,351 @@
1
+ # Githuh — GitHub API Client
2
+
3
+ [![Ruby](https://github.com/kigster/githuh/workflows/Ruby/badge.svg)](https://github.com/kigster/githuh/actions?query=workflow%3ARuby)
4
+ ![Coverage](docs/img/badge.svg)
5
+
6
+ As in... *git? huh?*
7
+
8
+ Githuh is a GitHub API client wrapper built on top of [Octokit](https://github.com/octokit/octokit.rb), using an extensible `dry-cli` command pattern. It is designed as a batteries-included CLI for tasks that are tedious to do through the GitHub web UI — exporting issues, generating repository listings, and (now) LLM-summarizing READMEs into human-readable descriptions.
9
+
10
+ ## Table of Contents
11
+
12
+ - [Features](#features)
13
+ - [Installation](#installation)
14
+ - [Authentication](#authentication)
15
+ - [Commands](#commands)
16
+ - [`githuh version`](#githuh-version)
17
+ - [`githuh user info`](#githuh-user-info)
18
+ - [`githuh repo list`](#githuh-repo-list)
19
+ - [`githuh issue export`](#githuh-issue-export)
20
+ - [Global Options](#global-options)
21
+ - [LLM Support](#llm-support)
22
+ - [Contributing](#contributing)
23
+ - [License](#license)
24
+
25
+ ## Features
26
+
27
+ - **Repository listing** in Markdown or JSON, with fork/private filtering
28
+ - **LLM-generated descriptions** — fetch each repo's README and summarize it into a flowing 5–6 sentence blurb using either Anthropic (Claude) or OpenAI
29
+ - **Issue export** to JSON or [Pivotal Tracker-compatible CSV](https://www.pivotaltracker.com/help/articles/csv_import_export), with configurable label → point mapping
30
+ - **User info** for the currently-authenticated GitHub user
31
+ - **In-process architecture** — every command is also runnable inside another Ruby process for scripting and testing (~89% test coverage via Aruba in-process)
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ gem install githuh
37
+ ```
38
+
39
+ Or add it to your `Gemfile`:
40
+
41
+ ```ruby
42
+ gem 'githuh'
43
+ ```
44
+
45
+ ## Authentication
46
+
47
+ Githuh reads your GitHub token, in priority order, from:
48
+
49
+ 1. The `--api-token=<token>` CLI flag
50
+ 1. The `GITHUB_TOKEN` environment variable
51
+ 1. `user.token` in your global git config
52
+
53
+ To set the token globally in git (recommended):
54
+
55
+ ```bash
56
+ git config --global --add user.token <your-github-pat>
57
+ ```
58
+
59
+ To set it for a single session:
60
+
61
+ ```bash
62
+ export GITHUB_TOKEN=<your-github-pat>
63
+ ```
64
+
65
+ Alternatively, use a local `.env` file (see [LLM Support](#llm-support) below — Githuh auto-loads `.env` from the current directory and from `$HOME`).
66
+
67
+ ## Commands
68
+
69
+ All invocations assume `githuh` is on your `PATH`. If running from a source checkout use `bundle exec exe/githuh` instead.
70
+
71
+ ### `githuh version`
72
+
73
+ Print the current version string (no color, no banner — scriptable):
74
+
75
+ ```bash
76
+ githuh version
77
+ # => 0.4.0
78
+ ```
79
+
80
+ Aliases: `githuh v`, `githuh -v`, `githuh --version`.
81
+
82
+ ______________________________________________________________________
83
+
84
+ ### `githuh user info`
85
+
86
+ Print information about the currently authenticated user.
87
+
88
+ ```
89
+ Command:
90
+ githuh user info
91
+
92
+ Options:
93
+ --api-token=VALUE # Github API token; if not given, user.token is read from ~/.gitconfig
94
+ --per-page=VALUE # Pagination page size for Github API, default: 20
95
+ --[no-]info # Print UI elements, like the progress bar, default: true
96
+ --[no-]verbose # Print additional debugging info, default: false
97
+ --help, -h # Print this help
98
+ ```
99
+
100
+ #### Examples
101
+
102
+ ```bash
103
+ # Default invocation (uses token from git config or GITHUB_TOKEN)
104
+ githuh user info
105
+
106
+ # Supply the token explicitly
107
+ githuh user info --api-token=ghp_XXXXXXXXXXXXXXXXXXXX
108
+
109
+ # Suppress the summary box (handy for piping)
110
+ githuh user info --no-info
111
+
112
+ # Verbose, for debugging auth/permissions issues
113
+ githuh user info --verbose
114
+ ```
115
+
116
+ ______________________________________________________________________
117
+
118
+ ### `githuh repo list`
119
+
120
+ List the authenticated user's owned repositories and render them as Markdown or JSON. The default output file is `<username>.repositories.<format>`.
121
+
122
+ ```
123
+ Command:
124
+ githuh repo list
125
+
126
+ Options:
127
+ --api-token=VALUE # Github API token; if not given, user.token is read from ~/.gitconfig
128
+ --per-page=VALUE # Pagination page size for Github API, default: 20
129
+ --[no-]info # Print UI elements, like the progress bar, default: true
130
+ --[no-]verbose # Print additional debugging info, default: false
131
+ --file=VALUE # Output file, overrides <username>.repositories.<format>
132
+ --format=VALUE # Output format: (markdown/json), default: "markdown"
133
+ --forks=VALUE # Include or exclude forks: (exclude/include/only), default: "exclude"
134
+ --[no-]private # If specified, returns only private repos for true, public for false
135
+ --[no-]llm # Use LLM (ANTHROPIC_API_KEY or OPENAI_API_KEY) to summarize README, default: false
136
+ --help, -h # Print this help
137
+ ```
138
+
139
+ #### Examples
140
+
141
+ Default Markdown output with forks excluded, saved to `<username>.repositories.md`:
142
+
143
+ ```bash
144
+ githuh repo list
145
+ ```
146
+
147
+ Public repos only, custom output file:
148
+
149
+ ```bash
150
+ githuh repo list --no-private --file=my-public-repos.md
151
+ ```
152
+
153
+ JSON output for programmatic consumption:
154
+
155
+ ```bash
156
+ githuh repo list --format=json --file=repos.json
157
+ ```
158
+
159
+ Only forks — useful for auditing what you've forked vs. maintained:
160
+
161
+ ```bash
162
+ githuh repo list --forks=only
163
+ ```
164
+
165
+ Include everything, private + public + forks:
166
+
167
+ ```bash
168
+ githuh repo list --forks=include --private
169
+ ```
170
+
171
+ Bump the API page size (GitHub allows up to 100):
172
+
173
+ ```bash
174
+ githuh repo list --per-page=100
175
+ ```
176
+
177
+ Quiet mode (no progress bars, for scripting):
178
+
179
+ ```bash
180
+ githuh repo list --no-info --no-verbose
181
+ ```
182
+
183
+ LLM-summarized descriptions (see [LLM Support](#llm-support)):
184
+
185
+ ```bash
186
+ githuh repo list --llm
187
+ ```
188
+
189
+ All options combined — public repos, Markdown format, LLM summaries, verbose:
190
+
191
+ ```bash
192
+ githuh repo list \
193
+ --format=markdown \
194
+ --no-private \
195
+ --forks=exclude \
196
+ --llm \
197
+ --verbose \
198
+ --file=portfolio.md
199
+ ```
200
+
201
+ ______________________________________________________________________
202
+
203
+ ### `githuh issue export`
204
+
205
+ Export issues for a given repository into JSON or [Pivotal Tracker-compatible CSV](https://www.pivotaltracker.com/help/articles/csv_import_export). The default output file is `<username>.<repo>.issues.<format>`.
206
+
207
+ ```
208
+ Command:
209
+ githuh issue export REPO
210
+
211
+ Arguments:
212
+ REPO # REQUIRED Name of the repo, eg "rails/rails"
213
+
214
+ Options:
215
+ --api-token=VALUE # Github API token; if not given, user.token is read from ~/.gitconfig
216
+ --per-page=VALUE # Pagination page size for Github API, default: 20
217
+ --[no-]info # Print UI elements, like the progress bar, default: true
218
+ --[no-]verbose # Print additional debugging info, default: false
219
+ --file=VALUE # Output file, overrides <username>.<repo>.issues.<format>
220
+ --format=VALUE # Output format: (json/csv), default: "csv"
221
+ --mapping=VALUE # YAML file with label to estimates mapping
222
+ --help, -h # Print this help
223
+ ```
224
+
225
+ #### Label-to-Estimate Mapping
226
+
227
+ When exporting to Pivotal Tracker CSV, GitHub labels can be mapped to point estimates. Create a YAML file like this:
228
+
229
+ ```yaml
230
+ ---
231
+ label-to-estimates:
232
+ Large: 5
233
+ Medium: 3
234
+ Small: 1
235
+ ```
236
+
237
+ …and pass it via `--mapping=<path>`. Any label listed in the file is converted to its numeric estimate; other labels pass through unchanged in the `Labels` column.
238
+
239
+ #### Examples
240
+
241
+ Export all open issues from `rails/rails` as CSV:
242
+
243
+ ```bash
244
+ githuh issue export rails/rails
245
+ ```
246
+
247
+ Explicit format and output file:
248
+
249
+ ```bash
250
+ githuh issue export rails/rails --format=json --file=rails-issues.json
251
+ ```
252
+
253
+ Pivotal Tracker CSV with a label mapping:
254
+
255
+ ```bash
256
+ githuh issue export kigster/githuh --mapping=config/label-mapping.yml
257
+ ```
258
+
259
+ Quiet, scripted invocation with an explicit token:
260
+
261
+ ```bash
262
+ githuh issue export kigster/githuh \
263
+ --api-token=ghp_XXXXXXXXXXXXXXXXXXXX \
264
+ --no-info \
265
+ --no-verbose \
266
+ --file=issues.csv
267
+ ```
268
+
269
+ ______________________________________________________________________
270
+
271
+ ## Global Options
272
+
273
+ The following options are available on every subcommand (`user info`, `repo list`, `issue export`):
274
+
275
+ | Option | Default | Description |
276
+ | --- | --- | --- |
277
+ | `--api-token=VALUE` | _(from env / git config)_ | GitHub personal access token |
278
+ | `--per-page=VALUE` | `20` | Pagination page size for the GitHub API |
279
+ | `--[no-]info` | `true` | Print UI elements like the progress bar and info boxes |
280
+ | `--[no-]verbose` | `false` | Print additional debugging info |
281
+ | `--help, -h` | — | Print contextual help and exit |
282
+
283
+ ## LLM Support
284
+
285
+ When you pass `--llm` to `repo list`, Githuh fetches each repo's README via the GitHub API and asks an LLM to summarize it into a 5–6 sentence description. This is far more informative than GitHub's single-line description field, especially for portfolios or internal directories.
286
+
287
+ ### Configuration
288
+
289
+ Set one of the following in your environment or in a `.env` file at the project root or `$HOME`:
290
+
291
+ ```bash
292
+ # Preferred (uses claude-haiku-4-5-20251001)
293
+ ANTHROPIC_API_KEY=sk-ant-...
294
+
295
+ # Fallback (uses gpt-4o-mini)
296
+ OPENAI_API_KEY=sk-...
297
+ ```
298
+
299
+ If both are set, Anthropic is preferred. If `--llm` is set but neither key is available, the command exits with a clear error.
300
+
301
+ A `.env` parser is built in (no `dotenv` gem required). `.env` is also in `.gitignore` by default to keep secrets out of commits.
302
+
303
+ ### Behavior
304
+
305
+ On `repo list --llm` you will see:
306
+
307
+ 1. An info box announcing LLM summarization (provider + model)
308
+ 1. The regular `Format / File / Forks` info box
309
+ 1. The pagination progress bar (magenta)
310
+ 1. An LLM progress bar — **yellow for Anthropic**, **green for OpenAI** — advancing once per repo
311
+ 1. A success box with the total record count
312
+
313
+ Each repo's description in the output is replaced with the LLM-generated summary. On any per-repo failure (README fetch error, LLM timeout, auth failure, etc.) the command falls back silently to GitHub's original description — use `--verbose` to see why.
314
+
315
+ ### Cost & Performance
316
+
317
+ For 30–50 repos, expect:
318
+
319
+ - **Wall-clock**: ~45–90 seconds (sequential — one README fetch + one LLM call per repo)
320
+ - **Cost**: roughly $0.01–$0.05 per run with the default models
321
+
322
+ ### Example
323
+
324
+ ```bash
325
+ # Complete portfolio run
326
+ githuh repo list \
327
+ --format=markdown \
328
+ --no-private \
329
+ --forks=exclude \
330
+ --llm \
331
+ --file=portfolio.md
332
+ ```
333
+
334
+ ## Contributing
335
+
336
+ Pull requests welcome at <https://github.com/kigster/githuh/pulls>.
337
+
338
+ Development setup:
339
+
340
+ ```bash
341
+ git clone git@github.com:kigster/githuh.git
342
+ cd githuh
343
+ bundle install
344
+ just test # run the rspec suite
345
+ just lint # run rubocop
346
+ just format # auto-correct rubocop offenses
347
+ ```
348
+
349
+ ## License
350
+
351
+ © 2020–present Konstantin Gredeskoul, released under the [MIT License](LICENSE.txt).
data/Rakefile CHANGED
@@ -1,20 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'bundler/gem_tasks'
4
- require 'rspec/core/rake_task'
5
- require 'timeout'
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "timeout"
6
+ require "yard"
6
7
 
7
8
  def shell(*args)
8
9
  puts "running: #{args.join(' ')}"
9
- system(args.join(' '))
10
+ system(args.join(" "))
10
11
  end
11
12
 
12
13
  task :clean do
13
- shell('rm -rf pkg/ tmp/ coverage/ doc/ ' )
14
+ shell("rm -rf pkg/ tmp/ coverage/ doc/ ")
14
15
  end
15
16
 
16
17
  task gem: [:build] do
17
- shell('gem install pkg/*')
18
+ shell("gem install pkg/*")
18
19
  end
19
20
 
20
21
  task permissions: [:clean] do
@@ -24,6 +25,12 @@ end
24
25
 
25
26
  task build: :permissions
26
27
 
28
+ YARD::Rake::YardocTask.new(:doc) do |t|
29
+ t.files = %w[lib/**/*.rb exe/*.rb - README.md LICENSE.txt WARRANTY.md CHANGELOG.md]
30
+ t.options.unshift("--title", '"FlowEngine — DSL + AST for buildiong complex flows in Ruby."')
31
+ t.after = -> { exec("open doc/index.html") } if RUBY_PLATFORM =~ /darwin/
32
+ end
33
+
27
34
  RSpec::Core::RakeTask.new(:spec)
28
35
 
29
36
  task default: :spec
@@ -0,0 +1,21 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="99" height="20">
3
+ <linearGradient id="b" x2="0" y2="100%">
4
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
5
+ <stop offset="1" stop-opacity=".1"/>
6
+ </linearGradient>
7
+ <mask id="a">
8
+ <rect width="99" height="20" rx="3" fill="#fff"/>
9
+ </mask>
10
+ <g mask="url(#a)">
11
+ <path fill="#555" d="M0 0h63v20H0z"/>
12
+ <path fill="#97CA00" d="M63 0h36v20H63z"/>
13
+ <path fill="url(#b)" d="M0 0h99v20H0z"/>
14
+ </g>
15
+ <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
16
+ <text x="31.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
17
+ <text x="31.5" y="14">coverage</text>
18
+ <text x="80" y="15" fill="#010101" fill-opacity=".3">89%</text>
19
+ <text x="80" y="14">89%</text>
20
+ </g>
21
+ </svg>
Binary file
@@ -11,7 +11,7 @@
11
11
  <g fill="#fff" text-anchor="middle" font-weight="500" font-family="Consolas,DejaVu Sans,Verdana,Geneva,sans-serif" font-size="10">
12
12
  <text x="40.5" y="15" fill="#010101" fill-opacity=".3">COVERAGE</text>
13
13
  <text x="41.5" y="14">COVERAGE</text>
14
- <text x="105.5" y="15" fill="#010101" fill-opacity=".3">68%</text>
15
- <text x="106.5" y="14">68%</text>
14
+ <text x="105.5" y="15" fill="#010101" fill-opacity=".3">0%</text>
15
+ <text x="106.5" y="14">0%</text>
16
16
  </g>
17
17
  </svg>
data/githuh.gemspec CHANGED
@@ -22,24 +22,19 @@ Gem::Specification.new do |spec|
22
22
  spec.bindir = 'exe'
23
23
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
24
24
  spec.require_paths = ['lib']
25
- spec.required_ruby_version = '>= 2.3'
25
+ spec.required_ruby_version = '~> 4.0'
26
26
 
27
27
  spec.add_dependency 'activesupport'
28
28
  spec.add_dependency 'colored2', '~> 3'
29
- spec.add_dependency 'dry-cli', '~> 0.6'
29
+ spec.add_dependency 'csv'
30
+ spec.add_dependency 'dry-cli'
31
+ spec.add_dependency 'faraday'
32
+ spec.add_dependency 'faraday-retry'
30
33
  spec.add_dependency 'hashie'
31
- spec.add_dependency 'octokit', '~> 4'
34
+ spec.add_dependency 'octokit'
32
35
  spec.add_dependency 'tty-box'
33
36
  spec.add_dependency 'tty-progressbar'
34
37
  spec.add_dependency 'tty-screen'
35
38
 
36
- spec.add_development_dependency 'aruba', '= 1.0.0'
37
- spec.add_development_dependency 'awesome_print', '~> 1'
38
- spec.add_development_dependency 'bundler', '~> 2'
39
- spec.add_development_dependency 'rake', '~> 13'
40
- spec.add_development_dependency 'rspec', '~> 3'
41
- spec.add_development_dependency 'rspec-its', '~> 1'
42
- spec.add_development_dependency 'rubocop'
43
- spec.add_development_dependency 'simplecov'
44
- spec.add_development_dependency 'simplecov-formatter-badge'
39
+ spec.metadata['rubygems_mfa_required'] = 'true'
45
40
  end
data/justfile ADDED
@@ -0,0 +1,57 @@
1
+ set shell := ["bash", "-lc"]
2
+
3
+ rbenv := 'eval "$(rbenv init -)"'
4
+
5
+ [no-exit-message]
6
+ recipes:
7
+ @just --choose
8
+
9
+ # Sync all dependencies
10
+ install:
11
+ {{rbenv}} && bundle install -j 12
12
+
13
+ upgrade:
14
+ {{rbenv}} && bundle update --bundler
15
+ {{rbenv}} && bundle update
16
+
17
+ # Lint and reformat files
18
+ lint-fix *args:
19
+ {{rbenv}} && bundle exec rubocop -a
20
+ {{rbenv}} && bundle exec rubocop --auto-gen-config
21
+ git add .
22
+
23
+ alias format := lint-fix
24
+
25
+ # Lint and reformat files
26
+ lint:
27
+ {{rbenv}} && bundle exec rubocop
28
+
29
+ # Run all the tests
30
+ test *args:
31
+ #!/usr/bin/env bash
32
+ {{rbenv}} && RAILS_ENV=test GITHUB_TOKEN=$(git config user.token) bundle exec rspec {{args}}
33
+
34
+ # Run tests with coverage
35
+ test-coverage: test
36
+ open coverage/index.html
37
+
38
+ doc:
39
+ {{rbenv}} && bundle exec rake doc
40
+ open doc/index.html
41
+
42
+ clean:
43
+ #!/usr/bin/env bash
44
+ find . -name .DS_Store -delete -print || true
45
+ rm -rf tmp/*
46
+
47
+ # Run all lefthook pre-commit hooks
48
+ ci:
49
+ {{rbenv}} && lefthook run pre-commit --all-files
50
+
51
+ changelog:
52
+ #!/usr/bin/env bash
53
+ export CHANGELOG_GITHUB_TOKEN=$(git config user.token)
54
+ command -v github_changelog_generator >/dev/null || brew install github_changelog_generator
55
+ github_changelog_generator --user kigster --repo githuh
56
+
57
+
data/lefthook.yml ADDED
@@ -0,0 +1,35 @@
1
+ output:
2
+ - summary
3
+ - failure
4
+
5
+ pre-commit:
6
+ parallel: true
7
+ jobs:
8
+ - name: lint
9
+ run: bundle exec rubocop -c .rubocop.yml {staged_files}
10
+ glob: "*.{rb,Gemfile}"
11
+ stage_fixed: true
12
+
13
+ - name: check for conflict markers and whitespace issues
14
+ run: git --no-pager diff --check
15
+
16
+ # If tests take >1 second, move this (or just the long-running tests) to pre-push.
17
+ - name: run tests
18
+ run: just test
19
+
20
+ - name: fix rubocop formatting issues
21
+ run: bundle exec rubocop -a {staged_files}
22
+ glob: "*.{rb,Gemfile,gemspec}"
23
+ stage_fixed: true
24
+
25
+ - name: spell check
26
+ run: codespell {staged_files}
27
+ glob: "*.{rb,md,gemspec}"
28
+
29
+ - name: format markdown
30
+ run: mdformat {staged_files}
31
+ glob: "*.md"
32
+ stage_fixed: true
33
+
34
+ - name: scan for secrets
35
+ run: detect-secrets-hook --baseline .secrets.baseline
@@ -34,19 +34,18 @@ module Githuh
34
34
  per_page: DEFAULT_PAGE_SIZE,
35
35
  verbose: false,
36
36
  info: true)
37
-
38
37
  self.context = Githuh
39
38
  self.verbose = verbose
40
39
  self.info = info
41
40
  self.token = api_token || determine_github_token
42
- self.per_page = per_page.to_i || DEFAULT_PAGE_SIZE
41
+ self.per_page = per_page.to_i
43
42
 
44
- if info
45
- begin
46
- print_userinfo
47
- rescue StandardError
48
- nil
49
- end
43
+ return unless info
44
+
45
+ begin
46
+ print_userinfo
47
+ rescue StandardError
48
+ nil
50
49
  end
51
50
  end
52
51
 
@@ -56,12 +55,12 @@ module Githuh
56
55
 
57
56
  protected
58
57
 
59
- def puts(*args)
60
- stdout.puts(*args)
58
+ def puts(*)
59
+ stdout.puts(*)
61
60
  end
62
61
 
63
- def warn(*args)
64
- stderr.puts(*args)
62
+ def warn(*)
63
+ stderr.puts(*)
65
64
  end
66
65
 
67
66
  def user_info
@@ -93,7 +92,7 @@ module Githuh
93
92
  end
94
93
 
95
94
  def determine_github_token
96
- @github_token ||= (ENV['GITHUB_TOKEN'] || `git config --global --get user.token`.chomp)
95
+ @github_token ||= ENV['GITHUB_TOKEN'] || `git config --global --get user.token`.chomp
97
96
 
98
97
  return @github_token unless @github_token.empty?
99
98
 
@@ -108,15 +107,18 @@ module Githuh
108
107
  def print_userinfo
109
108
  duration = DateTime.now - DateTime.parse(user_info[:created_at].to_s)
110
109
  years = (duration / 365).to_i
111
- months = ((duration - years * 365) / 30).to_i
112
- days = (duration - years * 365 - months * 30).to_i
110
+ months = ((duration - (years * 365)) / 30).to_i
111
+ days = (duration - (years * 365) - (months * 30)).to_i
113
112
 
114
113
  lines = []
115
- lines << sprintf(" Github API Token: %s", h("#{token[0..9]}#{'.' * 20}#{token[-11..-1]}"))
114
+ lines << sprintf(" Github API Token: %s", h("#{token[0..9]}#{'.' * 20}#{token[-11..]}"))
116
115
  lines << sprintf(" Current User: %s", h(user_info.login))
117
116
  lines << sprintf(" Public Repos: %s", h(user_info.public_repos.to_s))
118
117
  lines << sprintf(" Followers: %s", h(user_info.followers.to_s))
119
- lines << sprintf(" Member For: %s", h(sprintf("%d years, %d months, %d days", years, months, days)))
118
+ lines << sprintf(
119
+ " Member For: %s",
120
+ h("%<years>d years, %<months>d months, %<days>d days" % { years:, months:, days: })
121
+ )
120
122
 
121
123
  self.box = TTY::Box.frame(*lines,
122
124
  padding: 0,