carson 2.7.0 → 2.8.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fdf6978ed4ecdbef69aba67dba84b678d1145038ff47925a1dfbfdc777170135
4
- data.tar.gz: a79ce86bc59f79528e0bf73d51098cd8f1ef1c96c2034d98900c282e26d047c7
3
+ metadata.gz: 76c0a5589cad84c4aaba1fee42bd667a21a55cea4840092b2984ae9eaeb84f6c
4
+ data.tar.gz: 656462b041f6764fd38780b500945ca0389b8235d741805367613051a27bd9da
5
5
  SHA512:
6
- metadata.gz: 5c11f2bd0fa18ca630b6c51bc23aae320e96bc63cfd24359c685b4664dd45594c68e3333554a9628c0411833de6c4a1779ef18cdd1d6b048516b116848a88484
7
- data.tar.gz: c6d3810f02303d1dbe6f614bc85b6d4a4186a171fa08b9705f66a86cac206eaf1f7c63e67c8a346d5ec9fd0a4c3454fbedbf5fed7275e46a9f1d03f5bd6d55fb
6
+ metadata.gz: 14cc66d4582c9ddfb9c5e87ec8e550d1f97cbcf6701a499e730dea4c06b5598ccdc6e8f61057a921ddba2d6ac9799fed4b3191a1a6dc1e440c4bc7249eec06bd
7
+ data.tar.gz: ce5e6f40342c08378830563ae0188cf61964d1f4650cc0be47a8cf52cb72f02b4f6413fa2b8d15a3dc70e3714628897446ab852ab7b276ac68a98cff0e5d1d54
@@ -66,13 +66,6 @@ jobs:
66
66
  - name: Install pinned RuboCop
67
67
  run: gem install rubocop -v "${{ inputs.rubocop_version }}" --no-document
68
68
 
69
- - name: Align remote name for Carson
70
- working-directory: host
71
- run: |
72
- if ! git remote get-url github >/dev/null 2>&1; then
73
- git remote rename origin github
74
- fi
75
-
76
69
  - name: Carson audit
77
70
  working-directory: host
78
71
  env:
data/API.md CHANGED
@@ -15,8 +15,9 @@ carson <command> [subcommand] [arguments]
15
15
 
16
16
  | Command | Purpose |
17
17
  |---|---|
18
+ | `carson setup` | Interactive quiz to configure remote, main branch, workflow, and merge method. Writes `~/.carson/config.json`. |
18
19
  | `carson lint setup --source <path-or-git-url> [--ref <git-ref>] [--force]` | Seed or refresh `~/.carson/lint` policy files from an explicit source. |
19
- | `carson onboard [repo_path]` | Apply one-command baseline setup for a target git repository. |
20
+ | `carson onboard [repo_path]` | Apply one-command baseline setup for a target git repository. Auto-triggers `setup` on first run. |
20
21
  | `carson prepare` | Install or refresh Carson-managed global hooks. |
21
22
  | `carson refresh [repo_path]` | Re-apply hooks, templates, and audit after upgrading Carson. |
22
23
  | `carson offboard [repo_path]` | Remove Carson-managed host artefacts and detach Carson hooks path where applicable. |
data/MANUAL.md CHANGED
@@ -50,14 +50,25 @@ Policy layout: language config files sit directly under `CODING/` (flat layout,
50
50
  carson onboard /path/to/your-repo
51
51
  ```
52
52
 
53
+ On first run (no `~/.carson/config.json` exists), `onboard` launches `carson setup` — an interactive quiz that detects your remotes, main branch, and preferred workflow. In non-interactive environments (CI, pipes), Carson auto-detects settings silently.
54
+
53
55
  `onboard` performs:
54
- - Remote alignment using configured `git.remote` (default `github`).
56
+ - Interactive setup quiz (first run only).
57
+ - Remote detection and verification using configured `git.remote` (default `origin`).
55
58
  - Hook installation under `~/.carson/hooks/<version>/`.
56
59
  - Repository `core.hooksPath` alignment to Carson global hooks.
57
60
  - Commit-time governance gate via managed `pre-commit` hook.
58
61
  - Managed `.github/*` template synchronisation.
59
62
  - Initial governance audit.
60
63
 
64
+ ### Reconfigure later
65
+
66
+ ```bash
67
+ carson setup
68
+ ```
69
+
70
+ Re-run the interactive setup quiz to change your remote, main branch, workflow style, or merge method. Choices are saved to `~/.carson/config.json`.
71
+
61
72
  ### Step 3: Commit generated files
62
73
 
63
74
  After `onboard`, commit the generated `.github/*` changes in your repository. From this point the repository is governed.
data/RELEASE.md CHANGED
@@ -5,6 +5,109 @@ Release-note scope rule:
5
5
  - `RELEASE.md` records only version deltas, breaking changes, and migration actions.
6
6
  - Operational usage guides live in `MANUAL.md` and `API.md`.
7
7
 
8
+ ## 2.8.1 — Onboard UX and Install Cleanup
9
+
10
+ ### What changed
11
+
12
+ - **Concise onboard output.** `carson onboard` now prints a clean 8-line summary instead of verbose internal state (hook paths, template statuses, config lines). Tells users what happened, what needs attention, and what to do next.
13
+ - **Graceful handling of fresh repos.** Onboard no longer fails with a fatal error on repositories with no commits yet.
14
+ - **Suppressed RubyGems PATH warning.** The misleading `WARNING: You don't have ... in your PATH, gem executables will not run` message from `gem install --user-install` is now suppressed during installation. Carson symlinks the executable to `~/.carson/bin`, making the gem bin directory irrelevant.
15
+
16
+ ### What users must do now
17
+
18
+ 1. Upgrade Carson to `2.8.1`.
19
+
20
+ ### Breaking or removed behaviour
21
+
22
+ - None.
23
+
24
+ ### Upgrade steps
25
+
26
+ ```bash
27
+ cd ~/Dev/carson
28
+ git pull
29
+ bash install.sh
30
+ carson version
31
+ ```
32
+
33
+ ### Engineering Appendix
34
+
35
+ #### Modified components
36
+
37
+ - `lib/carson/runtime.rb` — added `concise?` flag and `with_captured_output` helper for suppressing sub-command detail during onboard.
38
+ - `lib/carson/runtime/local.rb` — rewrote `onboard!` to use concise orchestration (`onboard_apply!`), added `onboard_report_remote!` and `onboard_run_audit!` helpers, added `unless concise?` guards to `prepare!` and `template_apply!`.
39
+ - `install.sh` — capture `gem install` stderr and filter out RubyGems PATH warning.
40
+ - `script/install_global_carson.sh` — same PATH warning suppression.
41
+ - `test/runtime_govern_test.rb` — updated onboard output assertions.
42
+
43
+ #### Public interface and config changes
44
+
45
+ - No new CLI commands or config keys.
46
+ - Exit status contract unchanged.
47
+
48
+ ---
49
+
50
+ ## 2.8.0 — Interactive Setup and Remote Detection
51
+
52
+ ### What changed
53
+
54
+ - **`carson setup` command.** An interactive quiz that detects git remotes, main branch, workflow style, and merge method. Writes answers to `~/.carson/config.json`. In non-TTY environments, Carson auto-detects settings silently.
55
+ - **Auto-triggered on first onboard.** `carson onboard` now launches the setup quiz when no `~/.carson/config.json` exists. Existing users are not affected.
56
+ - **Remote renaming removed.** Carson no longer renames `origin` to `github` during onboard. Instead, it detects the existing remote and adapts. This respects the user's repository layout.
57
+ - **Default remote changed from `github` to `origin`.** The built-in default `git.remote` is now `origin`, matching the convention of most git hosting providers. Users who previously relied on the `github` default should run `carson setup` or set `git.remote` in config.
58
+ - **Post-install message.** `gem install carson` now displays a getting-started guide pointing to `carson onboard`.
59
+ - **CI lint fallback simplified.** `lint_target_files_for_pull_request` uses `config.git_remote` directly with a minimal fallback to `origin`.
60
+
61
+ ### What users must do now
62
+
63
+ 1. Upgrade Carson to `2.8.0`.
64
+ 2. If you relied on the `github` remote default, either rename your remote to `origin` or run `carson setup` to configure `git.remote`.
65
+
66
+ ### Breaking or removed behaviour
67
+
68
+ - `git.remote` default changed from `github` to `origin`.
69
+ - `carson onboard` no longer renames `origin` to `github`.
70
+ - The `align_remote_name_for_carson!` method has been removed.
71
+
72
+ ### Upgrade steps
73
+
74
+ ```bash
75
+ cd ~/Dev/carson
76
+ git pull
77
+ bash install.sh
78
+ carson version
79
+ carson setup
80
+ ```
81
+
82
+ ### Engineering Appendix
83
+
84
+ #### New files
85
+
86
+ - `lib/carson/runtime/setup.rb` — interactive quiz, remote/branch detection, config persistence.
87
+ - `test/runtime_setup_test.rb` — quiz, detection, and config merge tests.
88
+
89
+ #### Modified components
90
+
91
+ - `lib/carson/config.rb` — default `git.remote` changed from `"github"` to `"origin"`, added `attr_accessor :git_remote`.
92
+ - `lib/carson/runtime.rb` — added `in_stream:` parameter and `@in` attribute.
93
+ - `lib/carson/cli.rb` — added `"setup"` command dispatch, updated banner.
94
+ - `lib/carson/runtime/local.rb` — replaced `align_remote_name_for_carson!` with `report_detected_remote!`, updated `onboard!` to auto-trigger setup, updated `print_onboarding_guidance`.
95
+ - `lib/carson/runtime/audit.rb` — simplified `lint_target_files_for_pull_request` CI fallback.
96
+ - `test/runtime_govern_test.rb` — removed `origin` → `github` rename.
97
+ - `test/runtime_audit_lint_test.rb` — updated remote name from `github` to `origin`.
98
+ - `carson.gemspec` — added `spec.post_install_message`.
99
+ - `MANUAL.md` — documented `carson setup`, updated remote default.
100
+ - `API.md` — added `setup` command entry.
101
+
102
+ #### Public interface and config changes
103
+
104
+ - Added CLI command: `carson setup`.
105
+ - Default `git.remote` changed from `"github"` to `"origin"`.
106
+ - Runtime constructor accepts `in_stream:` keyword argument.
107
+ - Exit status contract unchanged.
108
+
109
+ ---
110
+
8
111
  ## 2.7.0 — Documentation and Test Fixes
9
112
 
10
113
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.7.0
1
+ 2.8.1
data/carson.gemspec CHANGED
@@ -19,6 +19,18 @@ Gem::Specification.new do |spec|
19
19
  "documentation_uri" => "https://github.com/wanghailei/carson/blob/main/MANUAL.md"
20
20
  }
21
21
 
22
+ spec.post_install_message = <<~MSG
23
+
24
+ \u29D3 Carson at your service.
25
+
26
+ Step into your project directory and run:
27
+
28
+ carson onboard
29
+
30
+ I'll walk you through everything from there.
31
+
32
+ MSG
33
+
22
34
  spec.bindir = "exe"
23
35
  spec.executables = [ "carson" ]
24
36
  spec.require_paths = [ "lib" ]
data/lib/carson/cli.rb CHANGED
@@ -44,7 +44,7 @@ module Carson
44
44
 
45
45
  def self.build_parser
46
46
  OptionParser.new do |opts|
47
- opts.banner = "Usage: carson [audit|sync|prune|prepare|inspect|onboard [repo_path]|refresh [repo_path]|offboard [repo_path]|template check|template apply|lint setup --source <path-or-git-url>|review gate|review sweep|govern [--dry-run] [--json] [--loop SECONDS]|housekeep|version]"
47
+ opts.banner = "Usage: carson [setup|audit|sync|prune|prepare|inspect|onboard [repo_path]|refresh [repo_path]|offboard [repo_path]|template check|template apply|lint setup --source <path-or-git-url>|review gate|review sweep|govern [--dry-run] [--json] [--loop SECONDS]|housekeep|version]"
48
48
  end
49
49
  end
50
50
 
@@ -187,6 +187,8 @@ module Carson
187
187
  return Runtime::EXIT_ERROR if command == :invalid
188
188
 
189
189
  case command
190
+ when "setup"
191
+ runtime.setup!
190
192
  when "audit"
191
193
  runtime.audit!
192
194
  when "sync"
data/lib/carson/config.rb CHANGED
@@ -6,7 +6,8 @@ module Carson
6
6
 
7
7
  # Config is built-in only for outsider mode; host repositories do not carry Carson config files.
8
8
  class Config
9
- attr_reader :git_remote, :main_branch, :protected_branches, :hooks_base_path, :required_hooks,
9
+ attr_accessor :git_remote
10
+ attr_reader :main_branch, :protected_branches, :hooks_base_path, :required_hooks,
10
11
  :path_groups, :template_managed_files, :lint_languages,
11
12
  :review_wait_seconds, :review_poll_seconds, :review_max_polls, :review_sweep_window_days,
12
13
  :review_sweep_states, :review_disposition_prefix, :review_risk_keywords,
@@ -28,7 +29,7 @@ module Carson
28
29
  def self.default_data
29
30
  {
30
31
  "git" => {
31
- "remote" => "github",
32
+ "remote" => "origin",
32
33
  "main_branch" => "main",
33
34
  "protected_branches" => [ "main", "master" ]
34
35
  },
@@ -205,19 +205,11 @@ module Carson
205
205
  base_ref = ENV.fetch( "GITHUB_BASE_REF", "" ).to_s.strip
206
206
  return nil if base_ref.empty?
207
207
 
208
- preferred_remote = config.git_remote.to_s.strip
209
- remote_name = nil
210
-
211
- remotes_stdout, _, remotes_success, = git_run( "remote" )
212
- if remotes_success
213
- available_remotes = remotes_stdout.lines.map { |line| line.to_s.strip }.reject( &:empty? )
214
- candidates = [ preferred_remote, "origin", "github" ].map( &:to_s ).map( &:strip ).reject( &:empty? ).uniq
215
- remote_name = candidates.find { |candidate| available_remotes.include?( candidate ) }
216
- remote_name ||= available_remotes.first unless available_remotes.empty?
208
+ remote_name = config.git_remote
209
+ unless git_remote_exists?( remote_name: remote_name )
210
+ remote_name = "origin" if git_remote_exists?( remote_name: "origin" )
217
211
  end
218
212
 
219
- remote_name ||= ( preferred_remote.empty? ? "origin" : preferred_remote )
220
-
221
213
  _, _, fetch_success, = git_run( "fetch", "--no-tags", "--depth", "1", remote_name, base_ref )
222
214
  return nil unless fetch_success
223
215
 
@@ -153,40 +153,43 @@ module Carson
153
153
  target_path = File.join( hooks_dir, hook_name )
154
154
  FileUtils.cp( source_path, target_path )
155
155
  FileUtils.chmod( 0o755, target_path )
156
- puts_line "hook_written: #{relative_path( target_path )}"
156
+ puts_line "hook_written: #{relative_path( target_path )}" unless concise?
157
157
  end
158
158
  git_system!( "config", "core.hooksPath", hooks_dir )
159
159
  File.write( File.join( hooks_dir, "workflow_style" ), config.workflow_style )
160
- puts_line "configured_hooks_path: #{hooks_dir}"
160
+ puts_line "configured_hooks_path: #{hooks_dir}" unless concise?
161
+ return EXIT_OK if concise?
162
+
161
163
  inspect!
162
164
  end
163
165
 
164
- # One-command onboarding for new repositories: align remote naming, install hooks,
165
- # apply templates, and produce a first audit report.
166
+ # One-command onboarding for new repositories: detect remote, install hooks,
167
+ # apply templates, and run initial audit.
166
168
  def onboard!
167
169
  fingerprint_status = block_if_outsider_fingerprints!
168
170
  return fingerprint_status unless fingerprint_status.nil?
169
171
 
170
- print_header "Onboard"
171
172
  unless inside_git_work_tree?
172
173
  puts_line "ERROR: #{repo_root} is not a git repository."
173
174
  return EXIT_ERROR
174
175
  end
175
- align_remote_name_for_carson!
176
- hook_status = prepare!
177
- return hook_status unless hook_status == EXIT_OK
178
176
 
179
- template_status = template_apply!
180
- return template_status unless template_status == EXIT_OK
177
+ repo_name = File.basename( repo_root )
178
+ puts_line ""
179
+ puts_line "Onboarding #{repo_name}..."
181
180
 
182
- audit_status = audit!
183
- if audit_status == EXIT_OK
184
- puts_line "OK: Carson onboard completed for #{repo_root}."
185
- elsif audit_status == EXIT_BLOCK
186
- puts_line "BLOCK: Carson onboard completed with policy blocks; resolve and rerun carson audit."
181
+ unless global_config_exists?
182
+ if self.in.respond_to?( :tty? ) && self.in.tty?
183
+ setup_status = setup!
184
+ return setup_status unless setup_status == EXIT_OK
185
+ else
186
+ silent_setup!
187
+ end
187
188
  end
188
- print_onboarding_guidance
189
- audit_status
189
+
190
+ onboard_apply!
191
+ ensure
192
+ @concise = false
190
193
  end
191
194
 
192
195
  # Re-applies hooks, templates, and audit after upgrading Carson.
@@ -277,29 +280,29 @@ module Carson
277
280
  fingerprint_status = block_if_outsider_fingerprints!
278
281
  return fingerprint_status unless fingerprint_status.nil?
279
282
 
280
- print_header "Template Sync Apply"
283
+ print_header "Template Sync Apply" unless concise?
281
284
  results = template_results
282
285
  applied = 0
283
286
  results.each do |entry|
284
287
  if entry.fetch( :status ) == "error"
285
- puts_line "template_file: #{entry.fetch( :file )} status=error reason=#{entry.fetch( :reason )}"
288
+ puts_line "template_file: #{entry.fetch( :file )} status=error reason=#{entry.fetch( :reason )}" unless concise?
286
289
  next
287
290
  end
288
291
 
289
292
  file_path = File.join( repo_root, entry.fetch( :file ) )
290
293
  if entry.fetch( :status ) == "ok"
291
- puts_line "template_file: #{entry.fetch( :file )} status=ok reason=in_sync"
294
+ puts_line "template_file: #{entry.fetch( :file )} status=ok reason=in_sync" unless concise?
292
295
  next
293
296
  end
294
297
 
295
298
  FileUtils.mkdir_p( File.dirname( file_path ) )
296
299
  File.write( file_path, entry.fetch( :applied_content ) )
297
- puts_line "template_file: #{entry.fetch( :file )} status=updated reason=#{entry.fetch( :reason )}"
300
+ puts_line "template_file: #{entry.fetch( :file )} status=updated reason=#{entry.fetch( :reason )}" unless concise?
298
301
  applied += 1
299
302
  end
300
303
 
301
304
  error_count = results.count { |entry| entry.fetch( :status ) == "error" }
302
- puts_line "template_apply_summary: updated=#{applied} error=#{error_count}"
305
+ puts_line "template_apply_summary: updated=#{applied} error=#{error_count}" unless concise?
303
306
  error_count.positive? ? EXIT_ERROR : EXIT_OK
304
307
  end
305
308
 
@@ -661,31 +664,75 @@ module Carson
661
664
  end
662
665
  end
663
666
 
664
- # Ensures Carson expected remote naming (`github`) while keeping existing
665
- # repositories safe when neither `github` nor `origin` exists.
666
- def align_remote_name_for_carson!
667
+ # Verifies configured remote exists and logs status without mutating remotes.
668
+ def report_detected_remote!
667
669
  if git_remote_exists?( remote_name: config.git_remote )
668
670
  puts_line "remote_ok: #{config.git_remote}"
669
- return
671
+ else
672
+ puts_line "WARN: remote '#{config.git_remote}' not found; run carson setup to configure."
670
673
  end
671
- if git_remote_exists?( remote_name: "origin" )
672
- git_system!( "remote", "rename", "origin", config.git_remote )
673
- puts_line "remote_renamed: origin -> #{config.git_remote}"
674
- return
675
- end
676
- puts_line "WARN: no #{config.git_remote} or origin remote configured; continue with local baseline only."
677
674
  end
678
675
 
676
+ # Concise onboard orchestration: hooks, templates, remote, audit, guidance.
677
+ def onboard_apply!
678
+ @concise = true
679
+
680
+ hook_status = prepare!
681
+ return hook_status unless hook_status == EXIT_OK
682
+ puts_line "Hooks installed (#{config.required_hooks.count} hooks)."
683
+
684
+ template_drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
685
+ template_status = template_apply!
686
+ return template_status unless template_status == EXIT_OK
687
+ if template_drift_count.positive?
688
+ puts_line "Templates synced (#{template_drift_count} file#{plural_suffix( count: template_drift_count )} updated)."
689
+ else
690
+ puts_line "Templates in sync."
691
+ end
692
+
693
+ onboard_report_remote!
694
+ audit_status = onboard_run_audit!
679
695
 
680
- def print_onboarding_guidance
681
- puts_line ""
682
- puts_line "Carson is ready. Current workflow: #{config.workflow_style}"
683
- puts_line ""
684
- puts_line "Customise in ~/.carson/config.json:"
685
- puts_line " { \"workflow\": { \"style\": \"branch\" } } — enforce PR-only merges"
686
- puts_line " { \"workflow\": { \"style\": \"trunk\" } } — allow direct main commits (default)"
687
696
  puts_line ""
688
- puts_line "Run carson refresh after changing config."
697
+ puts_line "Carson is ready. Workflow: #{config.workflow_style}"
698
+ puts_line "Reconfigure anytime: carson setup"
699
+ audit_status
700
+ end
701
+
702
+ # Friendly remote status for onboard output.
703
+ def onboard_report_remote!
704
+ if git_remote_exists?( remote_name: config.git_remote )
705
+ puts_line "Remote: #{config.git_remote} (connected)."
706
+ else
707
+ puts_line "Remote not configured yet — carson setup will walk you through it."
708
+ end
709
+ end
710
+
711
+ # Runs audit with captured output; reports summary instead of full detail.
712
+ def onboard_run_audit!
713
+ audit_error = nil
714
+ audit_status = with_captured_output { audit! }
715
+ rescue StandardError => e
716
+ audit_error = e
717
+ audit_status = EXIT_OK
718
+ ensure
719
+ return onboard_print_audit_result( status: audit_status, error: audit_error )
720
+ end
721
+
722
+ def onboard_print_audit_result( status:, error: )
723
+ if error
724
+ if error.message.to_s.match?( /HEAD|rev-parse/ )
725
+ puts_line "No commits yet — run carson audit after your first commit."
726
+ else
727
+ puts_line "Audit skipped — run carson audit for details."
728
+ end
729
+ return EXIT_OK
730
+ end
731
+
732
+ if status == EXIT_BLOCK
733
+ puts_line "Some checks need attention — run carson audit for details."
734
+ end
735
+ status
689
736
  end
690
737
 
691
738
  # Uses `git remote get-url` as existence check to avoid parsing remote lists.
@@ -0,0 +1,295 @@
1
+ module Carson
2
+ class Runtime
3
+ module Setup
4
+ WELL_KNOWN_REMOTES = %w[origin github upstream].freeze
5
+
6
+ def setup!
7
+ print_header "Setup"
8
+
9
+ unless inside_git_work_tree?
10
+ puts_line "WARN: not a git repository. Skipping remote and branch detection."
11
+ return write_setup_config( choices: {} )
12
+ end
13
+
14
+ if self.in.respond_to?( :tty? ) && self.in.tty?
15
+ interactive_setup!
16
+ else
17
+ silent_setup!
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def interactive_setup!
24
+ choices = {}
25
+
26
+ remote_choice = prompt_remote
27
+ choices[ "git.remote" ] = remote_choice unless remote_choice.nil?
28
+
29
+ branch_choice = prompt_main_branch
30
+ choices[ "git.main_branch" ] = branch_choice unless branch_choice.nil?
31
+
32
+ workflow_choice = prompt_workflow_style
33
+ choices[ "workflow.style" ] = workflow_choice unless workflow_choice.nil?
34
+
35
+ merge_choice = prompt_merge_method
36
+ choices[ "govern.merge.method" ] = merge_choice unless merge_choice.nil?
37
+
38
+ write_setup_config( choices: choices )
39
+ end
40
+
41
+ def silent_setup!
42
+ detected = detect_git_remote
43
+ choices = {}
44
+ if detected && detected != config.git_remote
45
+ choices[ "git.remote" ] = detected
46
+ puts_line "detected_remote: #{detected}"
47
+ elsif detected
48
+ puts_line "detected_remote: #{detected}"
49
+ else
50
+ puts_line "detected_remote: none"
51
+ end
52
+
53
+ branch = detect_main_branch
54
+ if branch && branch != config.main_branch
55
+ choices[ "git.main_branch" ] = branch
56
+ puts_line "detected_main_branch: #{branch}"
57
+ elsif branch
58
+ puts_line "detected_main_branch: #{branch}"
59
+ end
60
+
61
+ write_setup_config( choices: choices )
62
+ end
63
+
64
+ def prompt_remote
65
+ remotes = list_git_remotes
66
+ if remotes.empty?
67
+ puts_line "No remotes found. Carson will operate in local-only mode."
68
+ return nil
69
+ end
70
+
71
+ puts_line ""
72
+ puts_line "Git remote"
73
+ options = build_remote_options( remotes: remotes )
74
+ options << { label: "Other (enter name)", value: :other }
75
+
76
+ default_index = 0
77
+ choice = prompt_choice( options: options, default: default_index )
78
+
79
+ if choice == :other
80
+ prompt_custom_value( label: "Remote name" )
81
+ else
82
+ choice
83
+ end
84
+ end
85
+
86
+ def prompt_main_branch
87
+ puts_line ""
88
+ puts_line "Main branch"
89
+ options = build_main_branch_options
90
+ options << { label: "Other (enter name)", value: :other }
91
+
92
+ default_index = 0
93
+ choice = prompt_choice( options: options, default: default_index )
94
+
95
+ if choice == :other
96
+ prompt_custom_value( label: "Branch name" )
97
+ else
98
+ choice
99
+ end
100
+ end
101
+
102
+ def prompt_workflow_style
103
+ puts_line ""
104
+ puts_line "Workflow style"
105
+ options = [
106
+ { label: "trunk — commit directly to main (default)", value: "trunk" },
107
+ { label: "branch — enforce PR-only merges", value: "branch" }
108
+ ]
109
+ prompt_choice( options: options, default: 0 )
110
+ end
111
+
112
+ def prompt_merge_method
113
+ puts_line ""
114
+ puts_line "Merge method"
115
+ options = [
116
+ { label: "squash — one commit per PR (recommended)", value: "squash" },
117
+ { label: "rebase — linear history, individual commits", value: "rebase" },
118
+ { label: "merge — merge commits", value: "merge" }
119
+ ]
120
+ prompt_choice( options: options, default: 0 )
121
+ end
122
+
123
+ def prompt_choice( options:, default: )
124
+ options.each_with_index do |option, index|
125
+ puts_line " #{index + 1}) #{option.fetch( :label )}"
126
+ end
127
+ out.print "#{BADGE} Choice [#{default + 1}]: "
128
+ out.flush
129
+ raw = self.in.gets
130
+ return options[ default ].fetch( :value ) if raw.nil?
131
+
132
+ input = raw.to_s.strip
133
+ return options[ default ].fetch( :value ) if input.empty?
134
+
135
+ index = Integer( input ) - 1
136
+ return options[ default ].fetch( :value ) if index < 0 || index >= options.length
137
+
138
+ options[ index ].fetch( :value )
139
+ rescue ArgumentError
140
+ options[ default ].fetch( :value )
141
+ end
142
+
143
+ def prompt_custom_value( label: )
144
+ out.print "#{BADGE} #{label}: "
145
+ out.flush
146
+ raw = self.in.gets
147
+ return nil if raw.nil?
148
+
149
+ value = raw.to_s.strip
150
+ value.empty? ? nil : value
151
+ end
152
+
153
+ def build_remote_options( remotes: )
154
+ sorted = sort_remotes( remotes: remotes )
155
+ sorted.map do |entry|
156
+ name = entry.fetch( :name )
157
+ url = entry.fetch( :url )
158
+ { label: "#{name} (#{url})", value: name }
159
+ end
160
+ end
161
+
162
+ def sort_remotes( remotes: )
163
+ well_known = []
164
+ others = []
165
+ remotes.each do |entry|
166
+ if WELL_KNOWN_REMOTES.include?( entry.fetch( :name ) )
167
+ well_known << entry
168
+ else
169
+ others << entry
170
+ end
171
+ end
172
+ well_known.sort_by { |e| WELL_KNOWN_REMOTES.index( e.fetch( :name ) ) || 999 } + others.sort_by { |e| e.fetch( :name ) }
173
+ end
174
+
175
+ def build_main_branch_options
176
+ options = []
177
+ main_exists = branch_exists_locally_or_remote?( branch: "main" )
178
+ master_exists = branch_exists_locally_or_remote?( branch: "master" )
179
+
180
+ if main_exists
181
+ options << { label: "main", value: "main" }
182
+ options << { label: "master", value: "master" } if master_exists
183
+ elsif master_exists
184
+ options << { label: "master", value: "master" }
185
+ options << { label: "main", value: "main" }
186
+ else
187
+ options << { label: "main", value: "main" }
188
+ options << { label: "master", value: "master" }
189
+ end
190
+ options
191
+ end
192
+
193
+ def branch_exists_locally_or_remote?( branch: )
194
+ return true if branch_exists?( branch_name: branch )
195
+
196
+ remote = config.git_remote
197
+ _, _, success, = git_run( "rev-parse", "--verify", "#{remote}/#{branch}" )
198
+ success
199
+ end
200
+
201
+ def list_git_remotes
202
+ stdout_text, _, success, = git_run( "remote", "-v" )
203
+ return [] unless success
204
+
205
+ remotes = {}
206
+ stdout_text.lines.each do |line|
207
+ parts = line.strip.split( /\s+/ )
208
+ next if parts.length < 2
209
+
210
+ name = parts[ 0 ]
211
+ url = parts[ 1 ]
212
+ remotes[ name ] ||= url
213
+ end
214
+ remotes.map { |name, url| { name: name, url: url } }
215
+ end
216
+
217
+ def detect_git_remote
218
+ remotes = list_git_remotes
219
+ remote_names = remotes.map { |entry| entry.fetch( :name ) }
220
+ return nil if remote_names.empty?
221
+
222
+ return config.git_remote if remote_names.include?( config.git_remote )
223
+ return remote_names.first if remote_names.length == 1
224
+
225
+ candidate = WELL_KNOWN_REMOTES.find { |name| remote_names.include?( name ) }
226
+ return candidate unless candidate.nil?
227
+
228
+ remote_names.first
229
+ end
230
+
231
+ def detect_main_branch
232
+ return "main" if branch_exists_locally_or_remote?( branch: "main" )
233
+ return "master" if branch_exists_locally_or_remote?( branch: "master" )
234
+
235
+ nil
236
+ end
237
+
238
+ def write_setup_config( choices: )
239
+ config_data = build_config_data_from_choices( choices: choices )
240
+
241
+ config_path = Config.global_config_path( repo_root: repo_root )
242
+ if config_path.empty?
243
+ puts_line "WARN: unable to determine config path; skipping config write."
244
+ return EXIT_OK
245
+ end
246
+
247
+ existing_data = load_existing_config( path: config_path )
248
+ merged = Config.deep_merge( base: existing_data, overlay: config_data )
249
+
250
+ FileUtils.mkdir_p( File.dirname( config_path ) )
251
+ File.write( config_path, JSON.pretty_generate( merged ) )
252
+ puts_line ""
253
+ puts_line "Config saved to #{config_path}"
254
+
255
+ reload_config_after_setup!
256
+ EXIT_OK
257
+ end
258
+
259
+ def build_config_data_from_choices( choices: )
260
+ data = {}
261
+ choices.each do |key, value|
262
+ next if value.nil?
263
+
264
+ parts = key.split( "." )
265
+ current = data
266
+ parts[ 0..-2 ].each do |part|
267
+ current[ part ] ||= {}
268
+ current = current[ part ]
269
+ end
270
+ current[ parts.last ] = value
271
+ end
272
+ data
273
+ end
274
+
275
+ def load_existing_config( path: )
276
+ return {} unless File.file?( path )
277
+
278
+ JSON.parse( File.read( path ) )
279
+ rescue JSON::ParserError
280
+ {}
281
+ end
282
+
283
+ def reload_config_after_setup!
284
+ @config = Config.load( repo_root: repo_root )
285
+ end
286
+
287
+ def global_config_exists?
288
+ path = Config.global_config_path( repo_root: repo_root )
289
+ !path.empty? && File.file?( path )
290
+ end
291
+ end
292
+
293
+ include Setup
294
+ end
295
+ end
@@ -4,6 +4,7 @@
4
4
  require "fileutils"
5
5
  require "json"
6
6
  require "open3"
7
+ require "stringio"
7
8
  require "time"
8
9
 
9
10
  module Carson
@@ -22,11 +23,13 @@ module Carson
22
23
  DISPOSITION_TOKENS = %w[accepted rejected deferred].freeze
23
24
 
24
25
  # Runtime wiring for repository context, tool paths, and output streams.
25
- def initialize( repo_root:, tool_root:, out:, err: )
26
+ def initialize( repo_root:, tool_root:, out:, err:, in_stream: $stdin )
26
27
  @repo_root = repo_root
27
28
  @tool_root = tool_root
28
29
  @out = out
29
30
  @err = err
31
+ @in = in_stream
32
+ @concise = false
30
33
  @config = Config.load( repo_root: repo_root )
31
34
  @git_adapter = Adapters::Git.new( repo_root: repo_root )
32
35
  @github_adapter = Adapters::GitHub.new( repo_root: repo_root )
@@ -34,7 +37,23 @@ module Carson
34
37
 
35
38
  private
36
39
 
37
- attr_reader :repo_root, :tool_root, :out, :err, :config, :git_adapter, :github_adapter
40
+ attr_reader :repo_root, :tool_root, :out, :err, :in, :config, :git_adapter, :github_adapter
41
+
42
+ # Returns true when output should be minimal (used by onboard/refresh).
43
+ def concise?
44
+ @concise
45
+ end
46
+
47
+ # Runs a block with all output captured (suppressed from the user).
48
+ # Returns the block's return value; output is silently discarded.
49
+ def with_captured_output
50
+ saved_out, saved_err = @out, @err
51
+ @out = StringIO.new
52
+ @err = StringIO.new
53
+ yield
54
+ ensure
55
+ @out, @err = saved_out, saved_err
56
+ end
38
57
 
39
58
  # Current local branch name.
40
59
  def current_branch
@@ -187,3 +206,4 @@ require_relative "runtime/lint"
187
206
  require_relative "runtime/audit"
188
207
  require_relative "runtime/review"
189
208
  require_relative "runtime/govern"
209
+ require_relative "runtime/setup"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carson
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.7.0
4
+ version: 2.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang
@@ -56,6 +56,7 @@ files:
56
56
  - lib/carson/runtime/review/query_text.rb
57
57
  - lib/carson/runtime/review/sweep_support.rb
58
58
  - lib/carson/runtime/review/utility.rb
59
+ - lib/carson/runtime/setup.rb
59
60
  - lib/carson/version.rb
60
61
  - templates/.github/AGENTS.md
61
62
  - templates/.github/CLAUDE.md
@@ -70,6 +71,16 @@ metadata:
70
71
  changelog_uri: https://github.com/wanghailei/carson/blob/main/RELEASE.md
71
72
  bug_tracker_uri: https://github.com/wanghailei/carson/issues
72
73
  documentation_uri: https://github.com/wanghailei/carson/blob/main/MANUAL.md
74
+ post_install_message: |2+
75
+
76
+ ⧓ Carson at your service.
77
+
78
+ Step into your project directory and run:
79
+
80
+ carson onboard
81
+
82
+ I'll walk you through everything from there.
83
+
73
84
  rdoc_options: []
74
85
  require_paths:
75
86
  - lib
@@ -88,3 +99,4 @@ rubygems_version: 4.0.3
88
99
  specification_version: 4
89
100
  summary: Outsider governance runtime for repository hygiene and merge readiness.
90
101
  test_files: []
102
+ ...