jackherd 0.0.1 → 1.0.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.
Files changed (250) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +38 -0
  4. package/README.md +1122 -3
  5. package/assets/branding/README.md +62 -0
  6. package/assets/branding/jackherd-icon-256.png +0 -0
  7. package/assets/branding/jackherd-icon-512.png +0 -0
  8. package/assets/branding/jackherd-icon-64.png +0 -0
  9. package/assets/branding/jackherd-icon-small-32.png +0 -0
  10. package/assets/branding/jackherd-icon-small-64.png +0 -0
  11. package/assets/branding/jackherd-icon-small.svg +1 -0
  12. package/assets/branding/jackherd-icon.svg +1 -0
  13. package/assets/branding/jackherd-logo-1024.png +0 -0
  14. package/assets/branding/jackherd-logo-512.png +0 -0
  15. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  16. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  17. package/assets/branding/jackherd-logo-dark.svg +1 -0
  18. package/assets/branding/jackherd-logo-mark-1024.png +0 -0
  19. package/assets/branding/jackherd-logo-mark-512.png +0 -0
  20. package/assets/branding/jackherd-logo-mark.svg +1 -0
  21. package/assets/branding/jackherd-logo.svg +1 -0
  22. package/dist/chunk-R6MGHCD7.js +93 -0
  23. package/dist/config.d.ts +167 -0
  24. package/dist/config.js +401 -0
  25. package/dist/plan-format.d.ts +325 -0
  26. package/dist/plan-format.js +636 -0
  27. package/docs/lanes.mmd +9 -0
  28. package/docs/lanes.svg +1 -0
  29. package/docs/skills.mmd +10 -0
  30. package/docs/skills.svg +1 -0
  31. package/docs/workflow.svg +172 -0
  32. package/herdr-plugin/herdr-plugin.toml +22 -0
  33. package/herdr-plugin/open-plans.ts +43 -0
  34. package/herdr-plugin/restore-plans.ts +107 -0
  35. package/install.sh +157 -0
  36. package/kit/hooks/hooks/git-guard/decision.ts +438 -0
  37. package/kit/hooks/hooks/git-guard/index.ts +38 -0
  38. package/kit/hooks/hooks/git-guard/shell.ts +407 -0
  39. package/kit/hooks/hooks/watch-guard/background.ts +36 -0
  40. package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
  41. package/kit/hooks/hooks/watch-guard/index.ts +11 -0
  42. package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
  43. package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
  44. package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
  45. package/kit/hooks/hooks.json +30 -0
  46. package/kit/hooks/statusline.sh +72 -0
  47. package/kit/review/agents/devops.md +11 -0
  48. package/kit/review/agents/public-content.md +39 -0
  49. package/kit/review/agents/security-audit.md +11 -0
  50. package/kit/review/agents/uncle-bob.md +11 -0
  51. package/kit/review/skills/devops/SKILL.md +50 -0
  52. package/kit/review/skills/diagnose/SKILL.md +140 -0
  53. package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
  54. package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
  55. package/kit/review/skills/pre-commit/SKILL.md +100 -0
  56. package/kit/review/skills/review/SKILL.md +43 -0
  57. package/kit/review/skills/security-audit/SKILL.md +65 -0
  58. package/kit/review/skills/uncle-bob/SKILL.md +43 -0
  59. package/kit/rules/rules/kit/arc42.md +89 -0
  60. package/kit/rules/rules/kit/architecture-files.md +87 -0
  61. package/kit/rules/rules/kit/code-style.md +159 -0
  62. package/kit/rules/rules/kit/pull-requests.md +59 -0
  63. package/kit/rules/rules/kit/testing.md +91 -0
  64. package/kit/rules/rules/kit/writing-for-agents.md +110 -0
  65. package/kit/workflow/agents/explore.md +25 -0
  66. package/kit/workflow/agents/implement.md +16 -0
  67. package/kit/workflow/agents/plan.md +13 -0
  68. package/kit/workflow/agents/spec-review.md +96 -0
  69. package/kit/workflow/skills/follow/SKILL.md +48 -0
  70. package/kit/workflow/skills/grill/SKILL.md +60 -0
  71. package/kit/workflow/skills/implement/SKILL.md +107 -0
  72. package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
  73. package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
  74. package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
  75. package/kit/workflow/skills/plan/SKILL.md +118 -0
  76. package/kit/workflow/skills/plan/references/template-English.md +92 -0
  77. package/kit/workflow/skills/plan/references/template-German.md +92 -0
  78. package/kit/workflow/skills/start/SKILL.md +47 -0
  79. package/package.json +63 -5
  80. package/schemas/jackherd.schema.json +601 -0
  81. package/src/check/changes.ts +45 -0
  82. package/src/check/command-line.ts +12 -0
  83. package/src/check/command.ts +136 -0
  84. package/src/check/deferral-arrows.ts +41 -0
  85. package/src/check/execute.ts +29 -0
  86. package/src/check/glob.ts +15 -0
  87. package/src/check/plan-structure-check.ts +135 -0
  88. package/src/check/plan.ts +106 -0
  89. package/src/check/public-content-check.ts +32 -0
  90. package/src/check/public-content-facts.ts +177 -0
  91. package/src/check/public-content.ts +216 -0
  92. package/src/check/report.ts +55 -0
  93. package/src/check/result.ts +42 -0
  94. package/src/check/roots.ts +36 -0
  95. package/src/check/routing.ts +22 -0
  96. package/src/check/schema.ts +83 -0
  97. package/src/check/settings.ts +44 -0
  98. package/src/claude/paths.ts +14 -0
  99. package/src/cli.ts +46 -0
  100. package/src/config/command.ts +45 -0
  101. package/src/config/detect.ts +196 -0
  102. package/src/config/index.ts +2 -0
  103. package/src/config/json-schema.ts +5 -0
  104. package/src/config/legacy-names.ts +56 -0
  105. package/src/config/link-kinds.ts +25 -0
  106. package/src/config/location.ts +46 -0
  107. package/src/config/read-config.ts +42 -0
  108. package/src/config/repository-facts.ts +19 -0
  109. package/src/config/repository-values.ts +46 -0
  110. package/src/config/schema.ts +302 -0
  111. package/src/context/command.ts +39 -0
  112. package/src/context/coordinator-name.ts +16 -0
  113. package/src/context/fill.ts +69 -0
  114. package/src/context/index.ts +10 -0
  115. package/src/context/level.ts +22 -0
  116. package/src/context/options.ts +29 -0
  117. package/src/context/report.ts +42 -0
  118. package/src/context/session-log.ts +91 -0
  119. package/src/plan-format/drift.ts +69 -0
  120. package/src/plan-format/header.ts +27 -0
  121. package/src/plan-format/index.ts +9 -0
  122. package/src/plan-format/lanes.ts +128 -0
  123. package/src/plan-format/markdown.ts +98 -0
  124. package/src/plan-format/phase-tally.ts +55 -0
  125. package/src/plan-format/phases.ts +224 -0
  126. package/src/plan-format/plan-structure.ts +150 -0
  127. package/src/plan-format/progress.ts +202 -0
  128. package/src/plan-format/sprint-document.ts +69 -0
  129. package/src/plan-format/terminal-text.ts +28 -0
  130. package/src/plans/board-choice.ts +60 -0
  131. package/src/plans/board-pick.ts +72 -0
  132. package/src/plans/board-rows.ts +62 -0
  133. package/src/plans/command.ts +60 -0
  134. package/src/plans/ensure-panes.ts +71 -0
  135. package/src/plans/error-text.ts +5 -0
  136. package/src/plans/fetch.ts +96 -0
  137. package/src/plans/git.ts +145 -0
  138. package/src/plans/github.ts +35 -0
  139. package/src/plans/lane-discovery.ts +34 -0
  140. package/src/plans/lane-plans.ts +80 -0
  141. package/src/plans/layout.ts +881 -0
  142. package/src/plans/link-chips.ts +74 -0
  143. package/src/plans/live.ts +336 -0
  144. package/src/plans/markdown-html.ts +140 -0
  145. package/src/plans/options.ts +15 -0
  146. package/src/plans/phase-source.ts +71 -0
  147. package/src/plans/plan-chips.ts +75 -0
  148. package/src/plans/plan-panes.ts +206 -0
  149. package/src/plans/planned.ts +117 -0
  150. package/src/plans/pull-requests.ts +107 -0
  151. package/src/plans/restart-decision.ts +42 -0
  152. package/src/plans/selection.ts +352 -0
  153. package/src/plans/settings.ts +68 -0
  154. package/src/plans/single-flight.ts +25 -0
  155. package/src/plans/snapshot.ts +300 -0
  156. package/src/plans/sprint-export.ts +103 -0
  157. package/src/plans/sprint-html.ts +301 -0
  158. package/src/plans/sprint-page.ts +287 -0
  159. package/src/plans/supervisor.ts +124 -0
  160. package/src/plans/symbols.ts +54 -0
  161. package/src/plans/texts.ts +103 -0
  162. package/src/plans/unlisted-lanes.ts +45 -0
  163. package/src/plans/view.ts +68 -0
  164. package/src/plans/worked-phases.ts +53 -0
  165. package/src/sync/checksum.ts +13 -0
  166. package/src/sync/choice.ts +14 -0
  167. package/src/sync/command-context.ts +113 -0
  168. package/src/sync/command.ts +94 -0
  169. package/src/sync/error-message.ts +1 -0
  170. package/src/sync/gitignore.ts +45 -0
  171. package/src/sync/global-kit.ts +176 -0
  172. package/src/sync/herdr-plugin.ts +175 -0
  173. package/src/sync/if-changed.ts +109 -0
  174. package/src/sync/init-command.ts +239 -0
  175. package/src/sync/init.ts +136 -0
  176. package/src/sync/kit-hooks.ts +18 -0
  177. package/src/sync/kit-path.ts +77 -0
  178. package/src/sync/kit-source.ts +169 -0
  179. package/src/sync/kit.ts +9 -0
  180. package/src/sync/legacy-files.ts +116 -0
  181. package/src/sync/manifest.ts +44 -0
  182. package/src/sync/next-steps.ts +42 -0
  183. package/src/sync/plan-file.ts +154 -0
  184. package/src/sync/plan-hooks.ts +165 -0
  185. package/src/sync/project-fs.ts +132 -0
  186. package/src/sync/render.ts +60 -0
  187. package/src/sync/report.ts +96 -0
  188. package/src/sync/settings-hooks.ts +90 -0
  189. package/src/sync/settings-migration.ts +94 -0
  190. package/src/sync/settings-offer.ts +168 -0
  191. package/src/sync/setup-questions.ts +70 -0
  192. package/src/sync/setup.ts +52 -0
  193. package/src/sync/sync-plan.ts +120 -0
  194. package/src/sync/sync-result.ts +9 -0
  195. package/src/sync/sync.ts +307 -0
  196. package/src/sync/tools.ts +52 -0
  197. package/src/terminal/herdr.ts +91 -0
  198. package/src/terminal/index.ts +13 -0
  199. package/src/terminal/none.ts +7 -0
  200. package/src/terminal/open.ts +39 -0
  201. package/src/terminal/text.ts +159 -0
  202. package/src/terminal/types.ts +30 -0
  203. package/src/terminal/width.ts +90 -0
  204. package/src/update/command.ts +65 -0
  205. package/src/update/package-manager.ts +56 -0
  206. package/src/update/update.ts +105 -0
  207. package/src/version/installed-version.ts +32 -0
  208. package/src/version/release.ts +22 -0
  209. package/src/version/skill-diff.ts +63 -0
  210. package/src/version/started-kit.ts +14 -0
  211. package/src/vocabulary/index.ts +132 -0
  212. package/src/watch/base-tip.ts +18 -0
  213. package/src/watch/command.ts +919 -0
  214. package/src/watch/coordinator-fill.ts +76 -0
  215. package/src/watch/feedback-read.ts +73 -0
  216. package/src/watch/feedback.ts +308 -0
  217. package/src/watch/gh.ts +22 -0
  218. package/src/watch/github-read.ts +175 -0
  219. package/src/watch/herdr.ts +11 -0
  220. package/src/watch/json-guards.ts +9 -0
  221. package/src/watch/kit-update.ts +18 -0
  222. package/src/watch/lane-fill.ts +36 -0
  223. package/src/watch/lane-message.ts +99 -0
  224. package/src/watch/lane-progress.ts +44 -0
  225. package/src/watch/lane-report-file.ts +53 -0
  226. package/src/watch/lane-report.ts +270 -0
  227. package/src/watch/lane-search.ts +41 -0
  228. package/src/watch/lane-sources.ts +46 -0
  229. package/src/watch/lane-watch.ts +121 -0
  230. package/src/watch/monitor-tool.ts +19 -0
  231. package/src/watch/options.ts +79 -0
  232. package/src/watch/plan-badges-read.ts +30 -0
  233. package/src/watch/plan-badges.ts +66 -0
  234. package/src/watch/plan-views.ts +59 -0
  235. package/src/watch/preview-read.ts +60 -0
  236. package/src/watch/previews.ts +234 -0
  237. package/src/watch/pull-requests.ts +117 -0
  238. package/src/watch/single-report.ts +15 -0
  239. package/src/watch/skill-change.ts +61 -0
  240. package/src/watch/source-dry-run-command.ts +35 -0
  241. package/src/watch/source-dry-run.ts +124 -0
  242. package/src/watch/source-fields.ts +9 -0
  243. package/src/watch/source-path.ts +40 -0
  244. package/src/watch/source-run.ts +90 -0
  245. package/src/watch/sources.ts +344 -0
  246. package/src/watch/stream-output.ts +14 -0
  247. package/src/watch/texts.ts +76 -0
  248. package/src/watch/watch-events.ts +399 -0
  249. package/src/watch/watcher-lock.ts +145 -0
  250. package/src/watch/workflow-runs.ts +96 -0
@@ -0,0 +1,60 @@
1
+ import type { JackherdConfig } from '../config'
2
+ import { LANGUAGE_NAME, PLAN_VOCABULARY } from '../vocabulary'
3
+
4
+ const PLACEHOLDER = /\{\{\s*(kit\.[A-Za-z0-9_.-]*)\s*\}\}/g
5
+
6
+ const WORKTREE_INCLUDE_COPY =
7
+ 'cd <main-checkout> && git ls-files -z --others --ignored --exclude-from=.worktreeinclude | grep -zv node_modules | xargs -0 -I{} cp -p --parents {} <worktree>/'
8
+
9
+ /** The command that copies a lane's untracked files; the schema keeps quotes out of every path. */
10
+ const laneCopyCommand = (copy: readonly string[]): string =>
11
+ copy.length === 0
12
+ ? WORKTREE_INCLUDE_COPY
13
+ : `cd <main-checkout> && cp -p --parents -- ${copy.map((path) => `'${path}'`).join(' ')} <worktree>/`
14
+
15
+ const prefixed = (prefix: string, values: Record<string, string>): [string, string][] =>
16
+ Object.entries(values).map(([key, value]) => [`kit.${prefix}.${key}`, value])
17
+
18
+ /** Every placeholder a kit file may use, with its value for this project. */
19
+ const placeholderValues = (config: JackherdConfig): Map<string, string> => {
20
+ const { header, sections, columns, phase } = PLAN_VOCABULARY[config.planLanguage]
21
+ return new Map([
22
+ ['kit.responseLanguage', LANGUAGE_NAME[config.responseLanguage]],
23
+ ['kit.planLanguage', LANGUAGE_NAME[config.planLanguage]],
24
+ ['kit.planDirectory', config.planDirectory],
25
+ ['kit.baseBranch', config.baseBranch],
26
+ ['kit.worktreeDirectory', config.worktreeDirectory],
27
+ ...prefixed('model', config.models),
28
+ ...prefixed('command', config.commands),
29
+ ['kit.lanes.copyCommand', laneCopyCommand(config.lanes.copy)],
30
+ ['kit.lanes.lockFile', config.lanes.lockFile],
31
+ ...prefixed('board', config.board),
32
+ ...prefixed('heading', { ...header, ...sections, ...columns, phase }),
33
+ ])
34
+ }
35
+
36
+ /** The `kit.` placeholder names a template uses, in order of appearance. */
37
+ export const listPlaceholders = (template: string): string[] =>
38
+ [...template.matchAll(PLACEHOLDER)].map(([, name]) => name)
39
+
40
+ /**
41
+ * Fills the kit placeholders from the project config: `{{kit.model.<role>}}`,
42
+ * `{{kit.command.<test|typecheck|lint|format|install|dev>}}`, `{{kit.lanes.<copyCommand|lockFile>}}`,
43
+ * `{{kit.board.<pattern|file|term>}}`, `{{kit.heading.<key>}}` from the plan
44
+ * vocabulary, the two languages, the plan and worktree directory and the base branch. Other
45
+ * `{{…}}` text is left alone; an unknown `kit.` placeholder throws rather than reaching a project
46
+ * file unrendered. `overrides` win over the config: a global kit renders what each repository decides as
47
+ * `<name>` (`REPOSITORY_TOKENS`).
48
+ */
49
+ export const renderTemplate = (
50
+ template: string,
51
+ config: JackherdConfig,
52
+ overrides: ReadonlyMap<string, string> = new Map(),
53
+ ): string => {
54
+ const values = placeholderValues(config)
55
+ return template.replace(PLACEHOLDER, (_match, name: string) => {
56
+ const value = overrides.get(name) ?? values.get(name)
57
+ if (value === undefined) throw new Error(`Unknown kit placeholder {{${name}}}`)
58
+ return value
59
+ })
60
+ }
@@ -0,0 +1,96 @@
1
+ import { CLI_NAME } from '../version/release'
2
+ import { shownPath } from './kit-path'
3
+ import { FILE_OUTCOME, type FileOutcome } from './plan-file'
4
+ import { HOOK_OUTCOME, type HookOutcome } from './plan-hooks'
5
+ import type { SyncResult } from './sync-result'
6
+
7
+ export const SYNC_EXIT = { clean: 0, conflicts: 1, error: 2 } as const
8
+
9
+ type Outcome = FileOutcome | HookOutcome
10
+
11
+ const NEEDS_ATTENTION: readonly Outcome[] = [
12
+ FILE_OUTCOME.conflict,
13
+ FILE_OUTCOME.blocked,
14
+ HOOK_OUTCOME.conflict,
15
+ HOOK_OUTCOME.blocked,
16
+ ]
17
+
18
+ const COUNTED: readonly Outcome[] = [
19
+ FILE_OUTCOME.created,
20
+ FILE_OUTCOME.updated,
21
+ FILE_OUTCOME.accepted,
22
+ FILE_OUTCOME.removed,
23
+ FILE_OUTCOME.unchanged,
24
+ ]
25
+
26
+ const FILE_SECTIONS: { outcome: FileOutcome; heading: string; line: (path: string) => string }[] = [
27
+ {
28
+ outcome: FILE_OUTCOME.conflict,
29
+ heading: 'Conflicts: your file was kept, the kit version is next to it',
30
+ line: (path) => ` ${shownPath(path)} → ${shownPath(path)}.kit-new`,
31
+ },
32
+ {
33
+ outcome: FILE_OUTCOME.blocked,
34
+ heading: 'Blocked, nothing written: not a regular file, behind a symlink, or its .kit-new was edited',
35
+ line: (path) => ` ${shownPath(path)}`,
36
+ },
37
+ {
38
+ outcome: FILE_OUTCOME.kept,
39
+ heading: 'Kept with your changes (the kit has nothing new for them)',
40
+ line: (path) => ` ${shownPath(path)}`,
41
+ },
42
+ {
43
+ outcome: FILE_OUTCOME.deselected,
44
+ heading: 'Deleted by you, not recreated (`--accept <path>` brings one back)',
45
+ line: (path) => ` ${shownPath(path)}`,
46
+ },
47
+ {
48
+ outcome: FILE_OUTCOME.untracked,
49
+ heading: 'No longer in the kit, kept with your changes and no longer tracked',
50
+ line: (path) => ` ${shownPath(path)}`,
51
+ },
52
+ { outcome: FILE_OUTCOME.resolved, heading: 'Conflicts resolved', line: (path) => ` ${shownPath(path)}` },
53
+ ]
54
+
55
+ const HOOK_SECTIONS: { outcome: HookOutcome; heading: string }[] = [
56
+ { outcome: HOOK_OUTCOME.conflict, heading: 'Hook conflicts: the kit version is in .claude/settings.json.kit-new' },
57
+ {
58
+ outcome: HOOK_OUTCOME.blocked,
59
+ heading: 'Hooks blocked: .claude/settings.json.kit-new was edited, not overwritten',
60
+ },
61
+ { outcome: HOOK_OUTCOME.deselected, heading: 'Kit hooks you removed or edited, left as they are' },
62
+ ]
63
+
64
+ const RESOLVE_HINT = `Resolve a conflict: merge the .kit-new into your file and delete it, or run \`${CLI_NAME} sync --accept <path>\` to take the kit version.`
65
+
66
+ export const exitCodeOf = (result: SyncResult): number => {
67
+ const outcomes = [...result.files, ...result.hooks].map(({ outcome }) => outcome)
68
+ return outcomes.some((outcome) => NEEDS_ATTENTION.includes(outcome)) ? SYNC_EXIT.conflicts : SYNC_EXIT.clean
69
+ }
70
+
71
+ const summaryLine = (outcomes: Outcome[]): string => {
72
+ const counts = COUNTED.map((outcome) => ({ outcome, count: outcomes.filter((each) => each === outcome).length }))
73
+ const nonZero = counts.filter(({ count }) => count > 0)
74
+ if (nonZero.length === 0) return `${CLI_NAME} sync: nothing to do`
75
+ return `${CLI_NAME} sync: ${nonZero.map(({ outcome, count }) => `${count} ${outcome}`).join(', ')}`
76
+ }
77
+
78
+ const section = (heading: string, lines: string[]): string[] => (lines.length > 0 ? ['', `${heading}:`, ...lines] : [])
79
+
80
+ export const formatReport = (result: SyncResult): string => {
81
+ const fileLines = FILE_SECTIONS.flatMap(({ outcome, heading, line }) =>
82
+ section(
83
+ heading,
84
+ result.files.filter((file) => file.outcome === outcome).map(({ path }) => line(path)),
85
+ ),
86
+ )
87
+ const hookLines = HOOK_SECTIONS.flatMap(({ outcome, heading }) =>
88
+ section(
89
+ heading,
90
+ result.hooks.filter((hook) => hook.outcome === outcome).map(({ name }) => ` ${name}`),
91
+ ),
92
+ )
93
+ const hint = exitCodeOf(result) === SYNC_EXIT.conflicts ? ['', RESOLVE_HINT] : []
94
+ const summary = summaryLine([...result.files, ...result.hooks].map(({ outcome }) => outcome))
95
+ return [...(result.notes ?? []), summary, ...fileLines, ...hookLines, ...hint].join('\n')
96
+ }
@@ -0,0 +1,90 @@
1
+ import { z } from 'zod'
2
+ import { canonicalJson, checksum } from './checksum'
3
+
4
+ export type HookHandler = Record<string, unknown>
5
+
6
+ export type HookGroup = { matcher?: string; hooks: HookHandler[]; [key: string]: unknown }
7
+
8
+ export type Settings = { hooks?: Record<string, HookGroup[]>; [key: string]: unknown }
9
+
10
+ export type SettingsResult = { ok: true; settings: Settings } | { ok: false; reason: string }
11
+
12
+ export const HOOK_NAME = /^[a-z0-9][a-z0-9-]*$/
13
+
14
+ /** One handler in `.claude/settings.json`, addressed by the event and matcher of its group. */
15
+ export const hookDefinitionSchema = z.strictObject({
16
+ event: z.string().min(1),
17
+ matcher: z.string().optional(),
18
+ handler: z.record(z.string(), z.unknown()),
19
+ })
20
+
21
+ export type HookDefinition = z.infer<typeof hookDefinitionSchema>
22
+
23
+ const hookGroupSchema = z.looseObject({
24
+ matcher: z.string().optional(),
25
+ hooks: z.array(z.record(z.string(), z.unknown())),
26
+ })
27
+
28
+ const settingsSchema = z.looseObject({ hooks: z.record(z.string(), z.array(hookGroupSchema)).optional() })
29
+
30
+ export const parseSettings = (raw: unknown): SettingsResult => {
31
+ const parsed = settingsSchema.safeParse(raw)
32
+ if (!parsed.success) return { ok: false, reason: z.prettifyError(parsed.error) }
33
+ // The raw object, not `parsed.data`: Zod moves known keys first, which would reorder the file.
34
+ return { ok: true, settings: raw as Settings }
35
+ }
36
+
37
+ const sameMatcher = (left: string | undefined, right: string | undefined): boolean => (left ?? '') === (right ?? '')
38
+
39
+ /**
40
+ * The identity of a kit hook: a checksum over event, matcher and the whole handler. A handler
41
+ * edited by hand gets a different key, so sync no longer recognises it as its own and leaves it.
42
+ */
43
+ export const hookKey = (hook: HookDefinition): string =>
44
+ checksum(canonicalJson({ event: hook.event, matcher: hook.matcher ?? '', handler: hook.handler }))
45
+
46
+ const isHandler = (hook: HookDefinition) => {
47
+ const wanted = canonicalJson(hook.handler)
48
+ return (handler: HookHandler): boolean => canonicalJson(handler) === wanted
49
+ }
50
+
51
+ /** Own keys only, so an event named `__proto__` or `constructor` reads as absent. */
52
+ const groupsOf = (settings: Settings, event: string): HookGroup[] =>
53
+ settings.hooks && Object.hasOwn(settings.hooks, event) ? settings.hooks[event] : []
54
+
55
+ export const containsHook = (settings: Settings, hook: HookDefinition): boolean =>
56
+ groupsOf(settings, hook.event).some(
57
+ (group) => sameMatcher(group.matcher, hook.matcher) && group.hooks.some(isHandler(hook)),
58
+ )
59
+
60
+ /** Appends the hook in a group of its own, so no project group is rewritten. Returns a copy. */
61
+ export const addHook = (settings: Settings, hook: HookDefinition): Settings => {
62
+ const group: HookGroup =
63
+ hook.matcher === undefined ? { hooks: [hook.handler] } : { matcher: hook.matcher, hooks: [hook.handler] }
64
+ const hooks = settings.hooks ?? {}
65
+ return { ...settings, hooks: { ...hooks, [hook.event]: [...groupsOf(settings, hook.event), group] } }
66
+ }
67
+
68
+ const withoutFirstMatch = (groups: HookGroup[], hook: HookDefinition): HookGroup[] => {
69
+ const matches = isHandler(hook)
70
+ const groupIndex = groups.findIndex((group) => sameMatcher(group.matcher, hook.matcher) && group.hooks.some(matches))
71
+ if (groupIndex === -1) return groups
72
+ const group = groups[groupIndex]
73
+ const handlerIndex = group.hooks.findIndex(matches)
74
+ const remaining = group.hooks.filter((_handler, index) => index !== handlerIndex)
75
+ const replacement = remaining.length > 0 ? [{ ...group, hooks: remaining }] : []
76
+ return [...groups.slice(0, groupIndex), ...replacement, ...groups.slice(groupIndex + 1)]
77
+ }
78
+
79
+ /**
80
+ * Removes one copy of the hook's handler. Only a group the removal empties disappears, and with it
81
+ * an emptied event and an emptied `hooks` object. Returns a copy.
82
+ */
83
+ export const removeHook = (settings: Settings, hook: HookDefinition): Settings => {
84
+ if (!containsHook(settings, hook)) return settings
85
+ const remaining = withoutFirstMatch(groupsOf(settings, hook.event), hook)
86
+ const { [hook.event]: _removed, ...otherEvents } = settings.hooks ?? {}
87
+ const hooks = remaining.length > 0 ? { ...settings.hooks, [hook.event]: remaining } : otherEvents
88
+ const { hooks: _previous, ...rest } = settings
89
+ return Object.keys(hooks).length > 0 ? { ...rest, hooks } : rest
90
+ }
@@ -0,0 +1,94 @@
1
+ import { join } from 'node:path'
2
+ import { CLAUDE_DIRECTORY, SETTINGS_FILE } from '../claude/paths'
3
+ import { TOOL_DIRECTORY } from '../config/location'
4
+ import {
5
+ applySettingsOffer,
6
+ isLegacyHookCommand,
7
+ isObject,
8
+ readSettingsOffer,
9
+ SESSION_HOOK_COMMAND,
10
+ type SettingsObject,
11
+ type SettingsOffer,
12
+ } from './settings-offer'
13
+
14
+ /** Where the global install's status line runs from, as `~/.claude/settings.json` names it. */
15
+ export const GLOBAL_STATUS_LINE_COMMAND = `~/${TOOL_DIRECTORY}/statusline.sh`
16
+
17
+ /** The status line commands earlier global installs wrote: before the rename, and the interim of 0.28 on main. */
18
+ const LEGACY_STATUS_LINE_COMMANDS: readonly string[] = [
19
+ `~/${CLAUDE_DIRECTORY}/agent-kit/statusline.sh`,
20
+ `~/${CLAUDE_DIRECTORY}/jackherd/statusline.sh`,
21
+ ]
22
+
23
+ const SHOWN_SETTINGS = `~/${CLAUDE_DIRECTORY}/${SETTINGS_FILE}`
24
+
25
+ /** A status line on an old kit path points at the new one; any other key of it stays. */
26
+ export const LEGACY_STATUS_LINE_MIGRATION: SettingsOffer = {
27
+ find: (settings) => {
28
+ const current = settings.statusLine
29
+ if (!isObject(current) || !LEGACY_STATUS_LINE_COMMANDS.includes(String(current.command))) return { kind: 'present' }
30
+ return { kind: 'add', next: { ...settings, statusLine: { ...current, command: GLOBAL_STATUS_LINE_COMMAND } } }
31
+ },
32
+ }
33
+
34
+ const migrateHandler = (handler: unknown): unknown =>
35
+ isObject(handler) && typeof handler.command === 'string' && isLegacyHookCommand(handler.command)
36
+ ? { ...handler, command: SESSION_HOOK_COMMAND }
37
+ : handler
38
+
39
+ const migrateGroup = (group: unknown): unknown =>
40
+ isObject(group) && Array.isArray(group.hooks) ? { ...group, hooks: group.hooks.map(migrateHandler) } : group
41
+
42
+ const sessionStartOf = (settings: SettingsObject): unknown[] | null => {
43
+ const hooks = settings.hooks
44
+ return isObject(hooks) && Array.isArray(hooks.SessionStart) ? hooks.SessionStart : null
45
+ }
46
+
47
+ /** Our `SessionStart` hook as the old CLI ran it becomes the `jh` command; every other event, group and handler stays. */
48
+ export const LEGACY_HOOK_MIGRATION: SettingsOffer = {
49
+ find: (settings) => {
50
+ const groups = sessionStartOf(settings)
51
+ if (!groups) return { kind: 'present' }
52
+ const migrated = groups.map(migrateGroup)
53
+ if (JSON.stringify(migrated) === JSON.stringify(groups)) return { kind: 'present' }
54
+ return {
55
+ kind: 'add',
56
+ next: { ...settings, hooks: { ...(settings.hooks as SettingsObject), SessionStart: migrated } },
57
+ }
58
+ },
59
+ }
60
+
61
+ const HOOK_WAITS = `The SessionStart hook in ${SHOWN_SETTINGS} still runs agent-kit; once jh is on the PATH, change it to \`${SESSION_HOOK_COMMAND}\`.`
62
+
63
+ /** Applies one migration; the line to print, or null when there was nothing to change. */
64
+ const migrate = async (path: string, offer: SettingsOffer, done: string): Promise<string | null> => {
65
+ const read = await readSettingsOffer(path, offer)
66
+ if (read.plan.kind !== 'add') return null
67
+ const written = await applySettingsOffer(path, read)
68
+ return written.kind === 'written' ? done : `${SHOWN_SETTINGS} not changed: ${written.reason}`
69
+ }
70
+
71
+ /**
72
+ * Brings `~/.claude/settings.json` from an install before the rename to the new one: the status line moves with
73
+ * the script, the hook only when `jh` is on the PATH, since a hook naming a missing command fails every session.
74
+ * Writes nothing else and never throws; the lines say what changed or what is left.
75
+ */
76
+ export const migrateGlobalSettings = async (home: string, cliOnPath: boolean): Promise<string[]> => {
77
+ const path = join(home, CLAUDE_DIRECTORY, SETTINGS_FILE)
78
+ const statusLine = await migrate(
79
+ path,
80
+ LEGACY_STATUS_LINE_MIGRATION,
81
+ `Pointed the status line in ${SHOWN_SETTINGS} at ${GLOBAL_STATUS_LINE_COMMAND}.`,
82
+ )
83
+ const hookPending = (await readSettingsOffer(path, LEGACY_HOOK_MIGRATION)).plan.kind === 'add'
84
+ const hook = !hookPending
85
+ ? null
86
+ : cliOnPath
87
+ ? await migrate(
88
+ path,
89
+ LEGACY_HOOK_MIGRATION,
90
+ `Changed the SessionStart hook in ${SHOWN_SETTINGS} to \`${SESSION_HOOK_COMMAND}\`.`,
91
+ )
92
+ : HOOK_WAITS
93
+ return [statusLine, hook].filter((line): line is string => line !== null)
94
+ }
@@ -0,0 +1,168 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises'
3
+ import { CLI_NAME } from '../version/release'
4
+ import { errorMessage } from './error-message'
5
+
6
+ export type SettingsObject = Record<string, unknown>
7
+
8
+ /** What one offer finds in parsed settings: done already, a change to make, or a foreign value in the way. */
9
+ type OfferFinding =
10
+ | { kind: 'present' }
11
+ | { kind: 'add'; next: SettingsObject }
12
+ | { kind: 'foreign'; current: string; next: SettingsObject }
13
+ | { kind: 'unreadable'; reason: string }
14
+
15
+ /** A setting `init` offers to write into a `settings.json`: the SessionStart hook or the status line. */
16
+ export type SettingsOffer = { find: (settings: SettingsObject) => OfferFinding }
17
+
18
+ export type SettingsPlan =
19
+ | { kind: 'present' }
20
+ | { kind: 'add'; content: string }
21
+ | { kind: 'foreign'; current: string; content: string }
22
+ | { kind: 'unreadable'; reason: string }
23
+
24
+ /** The plan and the file it was made from, so applying it can tell whether the file changed meanwhile. */
25
+ export type SettingsRead = { plan: SettingsPlan; text: string | undefined; mode: number }
26
+
27
+ export type SettingsWrite = { kind: 'written' } | { kind: 'notWritten'; reason: string }
28
+
29
+ /** What the `SessionStart` hook runs: a global sync only when the installed kit changed. */
30
+ export const SESSION_HOOK_COMMAND = `${CLI_NAME} sync --global --if-changed`
31
+
32
+ const SESSION_START = 'SessionStart'
33
+
34
+ const SESSION_HOOK_GROUP = { matcher: 'startup', hooks: [{ type: 'command', command: SESSION_HOOK_COMMAND }] }
35
+
36
+ /** Claude Code creates `settings.json` world-readable; an existing file keeps its own mode. */
37
+ const NEW_FILE_MODE = 0o644
38
+
39
+ export const isObject = (value: unknown): value is SettingsObject =>
40
+ typeof value === 'object' && value !== null && !Array.isArray(value)
41
+
42
+ /** A foreign command is shown in a question; an escape sequence in it must not redraw the terminal. */
43
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: the class exists to replace control characters
44
+ const withoutControlCharacters = (text: string): string => text.replace(/[\x00-\x1f\x7f]/g, '?')
45
+
46
+ /** Our hook in any start form (`jh …`, `bunx jackherd …`, and the old `agent-kit …`) and any flag order. */
47
+ const isHookOf = (command: string, cli: RegExp): boolean => {
48
+ const words = command.trim().split(/\s+/)
49
+ const kit = words.findIndex((word) => cli.test(word))
50
+ return kit !== -1 && words[kit + 1] === 'sync' && words.includes('--global') && words.includes('--if-changed')
51
+ }
52
+
53
+ const isOurHookCommand = (command: string): boolean => isHookOf(command, /(^|\/)(jh|jackherd|agent-kit)$/)
54
+
55
+ /** The exact forms the CLI wrote before the rename; anything composed around them is the user's and stays. */
56
+ const LEGACY_HOOK = /^(bunx (@crossdelta\/)?)?agent-kit sync (--global --if-changed|--if-changed --global)$/
57
+
58
+ /** Our hook as the CLI started before the rename (`agent-kit …`, `bunx @crossdelta/agent-kit …`), nothing around it. */
59
+ export const isLegacyHookCommand = (command: string): boolean => LEGACY_HOOK.test(command.trim())
60
+
61
+ /** Reads only what it recognises; a group or handler of any other shape is someone else's and is skipped. */
62
+ const holdsOurHook = (groups: unknown[]): boolean =>
63
+ groups.some(
64
+ (group) =>
65
+ isObject(group) &&
66
+ Array.isArray(group.hooks) &&
67
+ group.hooks.some(
68
+ (handler) => isObject(handler) && typeof handler.command === 'string' && isOurHookCommand(handler.command),
69
+ ),
70
+ )
71
+
72
+ /** The hook as a group of its own appended to `SessionStart`; every other event, group and handler is copied. */
73
+ export const SESSION_HOOK_OFFER: SettingsOffer = {
74
+ find: (settings) => {
75
+ const hooks = settings.hooks ?? {}
76
+ if (!isObject(hooks)) return { kind: 'unreadable', reason: '`hooks` is not an object' }
77
+ const groups = Object.hasOwn(hooks, SESSION_START) ? hooks[SESSION_START] : []
78
+ if (!Array.isArray(groups)) return { kind: 'unreadable', reason: `\`hooks.${SESSION_START}\` is not a list` }
79
+ if (holdsOurHook(groups)) return { kind: 'present' }
80
+ return { kind: 'add', next: { ...settings, hooks: { ...hooks, [SESSION_START]: [...groups, SESSION_HOOK_GROUP] } } }
81
+ },
82
+ }
83
+
84
+ /** `command` as the status line; one of any other shape or command is the user's and is named, never dropped silently. */
85
+ export const statusLineOffer = (command: string): SettingsOffer => ({
86
+ find: (settings) => {
87
+ const next = { ...settings, statusLine: { type: 'command', command } }
88
+ if (!Object.hasOwn(settings, 'statusLine')) return { kind: 'add', next }
89
+ const current = settings.statusLine
90
+ if (isObject(current) && current.command === command) return { kind: 'present' }
91
+ const shown = isObject(current) && typeof current.command === 'string' ? current.command : JSON.stringify(current)
92
+ return { kind: 'foreign', current: withoutControlCharacters(shown), next }
93
+ },
94
+ })
95
+
96
+ const serialize = (settings: SettingsObject): string => `${JSON.stringify(settings, null, 2)}\n`
97
+
98
+ const parseSettings = (text: string): SettingsObject | string => {
99
+ try {
100
+ const value: unknown = JSON.parse(text)
101
+ return isObject(value) ? value : 'not a JSON object'
102
+ } catch (err) {
103
+ return `not valid JSON (${errorMessage(err)})`
104
+ }
105
+ }
106
+
107
+ /**
108
+ * What the offer takes in a `settings.json` holding `text` (undefined when there is none). Settings the offer
109
+ * cannot merge into without touching what is there are `unreadable`, never an error.
110
+ */
111
+ export const planSettingsOffer = (text: string | undefined, offer: SettingsOffer): SettingsPlan => {
112
+ const settings = text === undefined ? {} : parseSettings(text)
113
+ if (typeof settings === 'string') return { kind: 'unreadable', reason: settings }
114
+ const finding = offer.find(settings)
115
+ if (finding.kind === 'add') return { kind: 'add', content: serialize(finding.next) }
116
+ if (finding.kind === 'foreign') return { kind: 'foreign', current: finding.current, content: serialize(finding.next) }
117
+ return finding
118
+ }
119
+
120
+ const isMissing = (err: unknown): boolean => isObject(err) && err.code === 'ENOENT'
121
+
122
+ /** The file's text and mode; undefined text when there is none. Anything but a regular file is refused. */
123
+ const readSettingsFile = async (path: string): Promise<{ text: string | undefined; mode: number } | string> => {
124
+ try {
125
+ const entry = await lstat(path)
126
+ if (!entry.isFile()) return 'not a regular file (a symlink is never written through)'
127
+ return { text: await readFile(path, 'utf8'), mode: entry.mode & 0o777 }
128
+ } catch (err) {
129
+ return isMissing(err) ? { text: undefined, mode: NEW_FILE_MODE } : errorMessage(err)
130
+ }
131
+ }
132
+
133
+ /** Reads `path` and plans the offer against it; never throws. */
134
+ export const readSettingsOffer = async (path: string, offer: SettingsOffer): Promise<SettingsRead> => {
135
+ const file = await readSettingsFile(path)
136
+ if (typeof file === 'string')
137
+ return { plan: { kind: 'unreadable', reason: file }, text: undefined, mode: NEW_FILE_MODE }
138
+ return { plan: planSettingsOffer(file.text, offer), text: file.text, mode: file.mode }
139
+ }
140
+
141
+ /** Through a temporary file and a rename, so the settings are never half written. */
142
+ const replaceFile = async (path: string, content: string, mode: number): Promise<void> => {
143
+ const temporary = `${path}.jackherd-${randomBytes(6).toString('hex')}.tmp`
144
+ try {
145
+ await writeFile(temporary, content, { flag: 'wx', mode })
146
+ await rename(temporary, path)
147
+ } finally {
148
+ await rm(temporary, { force: true })
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Writes the planned change, unless the file changed since it was read (the user may have taken a while to
154
+ * answer): then nothing is written and the result says why. Never throws.
155
+ */
156
+ export const applySettingsOffer = async (path: string, read: SettingsRead): Promise<SettingsWrite> => {
157
+ if (read.plan.kind !== 'add' && read.plan.kind !== 'foreign')
158
+ return { kind: 'notWritten', reason: 'nothing to write' }
159
+ const now = await readSettingsFile(path)
160
+ if (typeof now === 'string') return { kind: 'notWritten', reason: now }
161
+ if (now.text !== read.text) return { kind: 'notWritten', reason: 'it changed while the question was open' }
162
+ try {
163
+ await replaceFile(path, read.plan.content, now.mode)
164
+ return { kind: 'written' }
165
+ } catch (err) {
166
+ return { kind: 'notWritten', reason: errorMessage(err) }
167
+ }
168
+ }
@@ -0,0 +1,70 @@
1
+ import { TERMINAL_ADAPTER, type TerminalAdapter } from '../config'
2
+ import { LANGUAGE_NAME, PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
3
+ import type { Choice } from './choice'
4
+ import type { CommandContext } from './command-context'
5
+ import { type SetupAnswers, type SetupOutcome, setupValues } from './setup'
6
+ import { INSTALLERS, type ToolPresence } from './tools'
7
+
8
+ const LANGUAGE_CHOICES: readonly Choice<PlanLanguage>[] = [PLAN_LANGUAGE.en, PLAN_LANGUAGE.de].map((value) => ({
9
+ value,
10
+ label: LANGUAGE_NAME[value],
11
+ }))
12
+
13
+ const ADAPTER_CHOICES: readonly Choice<TerminalAdapter>[] = [
14
+ { value: TERMINAL_ADAPTER.herdr, label: 'Yes, use herdr (agent states and context fill in the plans view)' },
15
+ { value: TERMINAL_ADAPTER.none, label: 'No, not now' },
16
+ ]
17
+
18
+ const RESPONSE_QUESTION = 'Which language should the skills answer you in?'
19
+
20
+ const PLAN_QUESTION = 'Which language should plans and boards be written in? It sets their headings.'
21
+
22
+ const ADAPTER_QUESTION =
23
+ 'Use herdr? It runs parallel lanes for /orchestrate and shows each agent in the plans view; /plan and /implement work without it.'
24
+
25
+ const INSTALL_QUESTION = `herdr is not installed. It is only needed for parallel lanes. Install it now with its official installer, \`${INSTALLERS.herdr}\`?`
26
+
27
+ /**
28
+ * The line after an install that left no herdr to run: a failed download or script ends with its exit code, and an
29
+ * installer that ends with 0 may still put herdr into a directory this process's PATH does not hold yet.
30
+ */
31
+ const installOutcome = (exitCode: number): string =>
32
+ exitCode === 0
33
+ ? `The herdr installer finished, but herdr is not on the PATH of this shell. Open a new shell and run \`herdr --version\`; if it is missing there too, run the installer yourself: ${INSTALLERS.herdr}`
34
+ : `The herdr installer ended with exit code ${exitCode}. Run it yourself when you want herdr: ${INSTALLERS.herdr}`
35
+
36
+ /**
37
+ * Offers the official herdr installer when herdr is missing and the platform has one; runs it only after a yes.
38
+ * Returns the tools as found afterwards. A failed install is a line, never a failed `init`.
39
+ */
40
+ const offerHerdrInstall = async (context: CommandContext, tools: ToolPresence): Promise<ToolPresence> => {
41
+ if (tools.herdr || !tools.herdrInstallable) return tools
42
+ if ((await context.ask(INSTALL_QUESTION)) !== true) return tools
43
+ context.log(`Running ${INSTALLERS.herdr}`)
44
+ const exitCode = context.runInstaller('herdr')
45
+ const found = context.detectTools()
46
+ if (!found.herdr) context.log(installOutcome(exitCode))
47
+ return found
48
+ }
49
+
50
+ /** The three answers, or null as soon as one cannot be asked (no terminal). */
51
+ const askAnswers = async (context: CommandContext, tools: ToolPresence): Promise<SetupAnswers | null> => {
52
+ const responseLanguage = await context.choose(RESPONSE_QUESTION, LANGUAGE_CHOICES, PLAN_LANGUAGE.en)
53
+ if (responseLanguage === null) return null
54
+ const planLanguage = await context.choose(PLAN_QUESTION, LANGUAGE_CHOICES, responseLanguage)
55
+ if (planLanguage === null) return null
56
+ const found = await offerHerdrInstall(context, tools)
57
+ const fallback = found.herdr ? TERMINAL_ADAPTER.herdr : TERMINAL_ADAPTER.none
58
+ const adapter = await context.choose(ADAPTER_QUESTION, ADAPTER_CHOICES, fallback)
59
+ return adapter === null ? null : { responseLanguage, planLanguage, adapter }
60
+ }
61
+
62
+ /**
63
+ * The setup for a new config. `answer` is the command line's `--yes`/`--no`: with it nothing is asked and nothing
64
+ * is installed, and the defaults apply, as they do without a terminal.
65
+ */
66
+ export const chooseSetup = async (context: CommandContext, answer: boolean | undefined): Promise<SetupOutcome> => {
67
+ const tools = context.detectTools()
68
+ const answers = answer === undefined ? await askAnswers(context, tools) : null
69
+ return setupValues(answers, tools)
70
+ }
@@ -0,0 +1,52 @@
1
+ import { TERMINAL_ADAPTER, type TerminalAdapter } from '../config'
2
+ import { LANGUAGE_NAME, PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
3
+ import type { ToolPresence } from './tools'
4
+
5
+ /** What `init` asks before it writes a new config. */
6
+ export type SetupAnswers = { responseLanguage: PlanLanguage; planLanguage: PlanLanguage; adapter: TerminalAdapter }
7
+
8
+ /** The config values the setup decides, in the shape of the config. */
9
+ export type SetupConfig = {
10
+ responseLanguage: PlanLanguage
11
+ planLanguage: PlanLanguage
12
+ terminal: { adapter: TerminalAdapter }
13
+ }
14
+
15
+ /** The values for a new config and, when nobody was asked, the line that says which defaults were taken. */
16
+ export type SetupOutcome = { values: SetupConfig; note: string | null }
17
+
18
+ /** What a run without questions takes: English for both, herdr as the adapter only where herdr is installed. */
19
+ export const setupDefaults = (tools: ToolPresence): SetupAnswers => ({
20
+ responseLanguage: PLAN_LANGUAGE.en,
21
+ planLanguage: PLAN_LANGUAGE.en,
22
+ adapter: tools.herdr ? TERMINAL_ADAPTER.herdr : TERMINAL_ADAPTER.none,
23
+ })
24
+
25
+ const toConfig = ({ responseLanguage, planLanguage, adapter }: SetupAnswers): SetupConfig => ({
26
+ responseLanguage,
27
+ planLanguage,
28
+ terminal: { adapter },
29
+ })
30
+
31
+ const languagesText = ({ responseLanguage, planLanguage }: SetupAnswers): string =>
32
+ responseLanguage === planLanguage
33
+ ? `replies and plans in ${LANGUAGE_NAME[responseLanguage]}`
34
+ : `replies in ${LANGUAGE_NAME[responseLanguage]}, plans in ${LANGUAGE_NAME[planLanguage]}`
35
+
36
+ const herdrFoundText = (tools: ToolPresence): string => (tools.herdr ? 'found herdr' : 'herdr not found')
37
+
38
+ const adapterText = (adapter: TerminalAdapter, tools: ToolPresence): string =>
39
+ `${adapter === TERMINAL_ADAPTER.herdr ? 'herdr as the terminal adapter' : 'no terminal adapter'} (${herdrFoundText(tools)})`
40
+
41
+ /**
42
+ * The setup for a new config. `answers` is null when nobody could be asked (`--yes`, `--no`, no terminal); the
43
+ * defaults of `setupDefaults` apply then, and the note names them.
44
+ */
45
+ export const setupValues = (answers: SetupAnswers | null, tools: ToolPresence): SetupOutcome => {
46
+ if (answers !== null) return { values: toConfig(answers), note: null }
47
+ const defaults = setupDefaults(tools)
48
+ return {
49
+ values: toConfig(defaults),
50
+ note: `Not asked about the setup: ${languagesText(defaults)}, ${adapterText(defaults.adapter, tools)}. Change them in the config.`,
51
+ }
52
+ }