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.
- package/CHANGELOG.md +65 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +1122 -3
- package/assets/branding/README.md +62 -0
- package/assets/branding/jackherd-icon-256.png +0 -0
- package/assets/branding/jackherd-icon-512.png +0 -0
- package/assets/branding/jackherd-icon-64.png +0 -0
- package/assets/branding/jackherd-icon-small-32.png +0 -0
- package/assets/branding/jackherd-icon-small-64.png +0 -0
- package/assets/branding/jackherd-icon-small.svg +1 -0
- package/assets/branding/jackherd-icon.svg +1 -0
- package/assets/branding/jackherd-logo-1024.png +0 -0
- package/assets/branding/jackherd-logo-512.png +0 -0
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -0
- package/assets/branding/jackherd-logo-mark-1024.png +0 -0
- package/assets/branding/jackherd-logo-mark-512.png +0 -0
- package/assets/branding/jackherd-logo-mark.svg +1 -0
- package/assets/branding/jackherd-logo.svg +1 -0
- package/dist/chunk-R6MGHCD7.js +93 -0
- package/dist/config.d.ts +167 -0
- package/dist/config.js +401 -0
- package/dist/plan-format.d.ts +325 -0
- package/dist/plan-format.js +636 -0
- package/docs/lanes.mmd +9 -0
- package/docs/lanes.svg +1 -0
- package/docs/skills.mmd +10 -0
- package/docs/skills.svg +1 -0
- package/docs/workflow.svg +172 -0
- package/herdr-plugin/herdr-plugin.toml +22 -0
- package/herdr-plugin/open-plans.ts +43 -0
- package/herdr-plugin/restore-plans.ts +107 -0
- package/install.sh +157 -0
- package/kit/hooks/hooks/git-guard/decision.ts +438 -0
- package/kit/hooks/hooks/git-guard/index.ts +38 -0
- package/kit/hooks/hooks/git-guard/shell.ts +407 -0
- package/kit/hooks/hooks/watch-guard/background.ts +36 -0
- package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
- package/kit/hooks/hooks/watch-guard/index.ts +11 -0
- package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
- package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
- package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
- package/kit/hooks/hooks.json +30 -0
- package/kit/hooks/statusline.sh +72 -0
- package/kit/review/agents/devops.md +11 -0
- package/kit/review/agents/public-content.md +39 -0
- package/kit/review/agents/security-audit.md +11 -0
- package/kit/review/agents/uncle-bob.md +11 -0
- package/kit/review/skills/devops/SKILL.md +50 -0
- package/kit/review/skills/diagnose/SKILL.md +140 -0
- package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
- package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
- package/kit/review/skills/pre-commit/SKILL.md +100 -0
- package/kit/review/skills/review/SKILL.md +43 -0
- package/kit/review/skills/security-audit/SKILL.md +65 -0
- package/kit/review/skills/uncle-bob/SKILL.md +43 -0
- package/kit/rules/rules/kit/arc42.md +89 -0
- package/kit/rules/rules/kit/architecture-files.md +87 -0
- package/kit/rules/rules/kit/code-style.md +159 -0
- package/kit/rules/rules/kit/pull-requests.md +59 -0
- package/kit/rules/rules/kit/testing.md +91 -0
- package/kit/rules/rules/kit/writing-for-agents.md +110 -0
- package/kit/workflow/agents/explore.md +25 -0
- package/kit/workflow/agents/implement.md +16 -0
- package/kit/workflow/agents/plan.md +13 -0
- package/kit/workflow/agents/spec-review.md +96 -0
- package/kit/workflow/skills/follow/SKILL.md +48 -0
- package/kit/workflow/skills/grill/SKILL.md +60 -0
- package/kit/workflow/skills/implement/SKILL.md +107 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
- package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
- package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
- package/kit/workflow/skills/plan/SKILL.md +118 -0
- package/kit/workflow/skills/plan/references/template-English.md +92 -0
- package/kit/workflow/skills/plan/references/template-German.md +92 -0
- package/kit/workflow/skills/start/SKILL.md +47 -0
- package/package.json +63 -5
- package/schemas/jackherd.schema.json +601 -0
- package/src/check/changes.ts +45 -0
- package/src/check/command-line.ts +12 -0
- package/src/check/command.ts +136 -0
- package/src/check/deferral-arrows.ts +41 -0
- package/src/check/execute.ts +29 -0
- package/src/check/glob.ts +15 -0
- package/src/check/plan-structure-check.ts +135 -0
- package/src/check/plan.ts +106 -0
- package/src/check/public-content-check.ts +32 -0
- package/src/check/public-content-facts.ts +177 -0
- package/src/check/public-content.ts +216 -0
- package/src/check/report.ts +55 -0
- package/src/check/result.ts +42 -0
- package/src/check/roots.ts +36 -0
- package/src/check/routing.ts +22 -0
- package/src/check/schema.ts +83 -0
- package/src/check/settings.ts +44 -0
- package/src/claude/paths.ts +14 -0
- package/src/cli.ts +46 -0
- package/src/config/command.ts +45 -0
- package/src/config/detect.ts +196 -0
- package/src/config/index.ts +2 -0
- package/src/config/json-schema.ts +5 -0
- package/src/config/legacy-names.ts +56 -0
- package/src/config/link-kinds.ts +25 -0
- package/src/config/location.ts +46 -0
- package/src/config/read-config.ts +42 -0
- package/src/config/repository-facts.ts +19 -0
- package/src/config/repository-values.ts +46 -0
- package/src/config/schema.ts +302 -0
- package/src/context/command.ts +39 -0
- package/src/context/coordinator-name.ts +16 -0
- package/src/context/fill.ts +69 -0
- package/src/context/index.ts +10 -0
- package/src/context/level.ts +22 -0
- package/src/context/options.ts +29 -0
- package/src/context/report.ts +42 -0
- package/src/context/session-log.ts +91 -0
- package/src/plan-format/drift.ts +69 -0
- package/src/plan-format/header.ts +27 -0
- package/src/plan-format/index.ts +9 -0
- package/src/plan-format/lanes.ts +128 -0
- package/src/plan-format/markdown.ts +98 -0
- package/src/plan-format/phase-tally.ts +55 -0
- package/src/plan-format/phases.ts +224 -0
- package/src/plan-format/plan-structure.ts +150 -0
- package/src/plan-format/progress.ts +202 -0
- package/src/plan-format/sprint-document.ts +69 -0
- package/src/plan-format/terminal-text.ts +28 -0
- package/src/plans/board-choice.ts +60 -0
- package/src/plans/board-pick.ts +72 -0
- package/src/plans/board-rows.ts +62 -0
- package/src/plans/command.ts +60 -0
- package/src/plans/ensure-panes.ts +71 -0
- package/src/plans/error-text.ts +5 -0
- package/src/plans/fetch.ts +96 -0
- package/src/plans/git.ts +145 -0
- package/src/plans/github.ts +35 -0
- package/src/plans/lane-discovery.ts +34 -0
- package/src/plans/lane-plans.ts +80 -0
- package/src/plans/layout.ts +881 -0
- package/src/plans/link-chips.ts +74 -0
- package/src/plans/live.ts +336 -0
- package/src/plans/markdown-html.ts +140 -0
- package/src/plans/options.ts +15 -0
- package/src/plans/phase-source.ts +71 -0
- package/src/plans/plan-chips.ts +75 -0
- package/src/plans/plan-panes.ts +206 -0
- package/src/plans/planned.ts +117 -0
- package/src/plans/pull-requests.ts +107 -0
- package/src/plans/restart-decision.ts +42 -0
- package/src/plans/selection.ts +352 -0
- package/src/plans/settings.ts +68 -0
- package/src/plans/single-flight.ts +25 -0
- package/src/plans/snapshot.ts +300 -0
- package/src/plans/sprint-export.ts +103 -0
- package/src/plans/sprint-html.ts +301 -0
- package/src/plans/sprint-page.ts +287 -0
- package/src/plans/supervisor.ts +124 -0
- package/src/plans/symbols.ts +54 -0
- package/src/plans/texts.ts +103 -0
- package/src/plans/unlisted-lanes.ts +45 -0
- package/src/plans/view.ts +68 -0
- package/src/plans/worked-phases.ts +53 -0
- package/src/sync/checksum.ts +13 -0
- package/src/sync/choice.ts +14 -0
- package/src/sync/command-context.ts +113 -0
- package/src/sync/command.ts +94 -0
- package/src/sync/error-message.ts +1 -0
- package/src/sync/gitignore.ts +45 -0
- package/src/sync/global-kit.ts +176 -0
- package/src/sync/herdr-plugin.ts +175 -0
- package/src/sync/if-changed.ts +109 -0
- package/src/sync/init-command.ts +239 -0
- package/src/sync/init.ts +136 -0
- package/src/sync/kit-hooks.ts +18 -0
- package/src/sync/kit-path.ts +77 -0
- package/src/sync/kit-source.ts +169 -0
- package/src/sync/kit.ts +9 -0
- package/src/sync/legacy-files.ts +116 -0
- package/src/sync/manifest.ts +44 -0
- package/src/sync/next-steps.ts +42 -0
- package/src/sync/plan-file.ts +154 -0
- package/src/sync/plan-hooks.ts +165 -0
- package/src/sync/project-fs.ts +132 -0
- package/src/sync/render.ts +60 -0
- package/src/sync/report.ts +96 -0
- package/src/sync/settings-hooks.ts +90 -0
- package/src/sync/settings-migration.ts +94 -0
- package/src/sync/settings-offer.ts +168 -0
- package/src/sync/setup-questions.ts +70 -0
- package/src/sync/setup.ts +52 -0
- package/src/sync/sync-plan.ts +120 -0
- package/src/sync/sync-result.ts +9 -0
- package/src/sync/sync.ts +307 -0
- package/src/sync/tools.ts +52 -0
- package/src/terminal/herdr.ts +91 -0
- package/src/terminal/index.ts +13 -0
- package/src/terminal/none.ts +7 -0
- package/src/terminal/open.ts +39 -0
- package/src/terminal/text.ts +159 -0
- package/src/terminal/types.ts +30 -0
- package/src/terminal/width.ts +90 -0
- package/src/update/command.ts +65 -0
- package/src/update/package-manager.ts +56 -0
- package/src/update/update.ts +105 -0
- package/src/version/installed-version.ts +32 -0
- package/src/version/release.ts +22 -0
- package/src/version/skill-diff.ts +63 -0
- package/src/version/started-kit.ts +14 -0
- package/src/vocabulary/index.ts +132 -0
- package/src/watch/base-tip.ts +18 -0
- package/src/watch/command.ts +919 -0
- package/src/watch/coordinator-fill.ts +76 -0
- package/src/watch/feedback-read.ts +73 -0
- package/src/watch/feedback.ts +308 -0
- package/src/watch/gh.ts +22 -0
- package/src/watch/github-read.ts +175 -0
- package/src/watch/herdr.ts +11 -0
- package/src/watch/json-guards.ts +9 -0
- package/src/watch/kit-update.ts +18 -0
- package/src/watch/lane-fill.ts +36 -0
- package/src/watch/lane-message.ts +99 -0
- package/src/watch/lane-progress.ts +44 -0
- package/src/watch/lane-report-file.ts +53 -0
- package/src/watch/lane-report.ts +270 -0
- package/src/watch/lane-search.ts +41 -0
- package/src/watch/lane-sources.ts +46 -0
- package/src/watch/lane-watch.ts +121 -0
- package/src/watch/monitor-tool.ts +19 -0
- package/src/watch/options.ts +79 -0
- package/src/watch/plan-badges-read.ts +30 -0
- package/src/watch/plan-badges.ts +66 -0
- package/src/watch/plan-views.ts +59 -0
- package/src/watch/preview-read.ts +60 -0
- package/src/watch/previews.ts +234 -0
- package/src/watch/pull-requests.ts +117 -0
- package/src/watch/single-report.ts +15 -0
- package/src/watch/skill-change.ts +61 -0
- package/src/watch/source-dry-run-command.ts +35 -0
- package/src/watch/source-dry-run.ts +124 -0
- package/src/watch/source-fields.ts +9 -0
- package/src/watch/source-path.ts +40 -0
- package/src/watch/source-run.ts +90 -0
- package/src/watch/sources.ts +344 -0
- package/src/watch/stream-output.ts +14 -0
- package/src/watch/texts.ts +76 -0
- package/src/watch/watch-events.ts +399 -0
- package/src/watch/watcher-lock.ts +145 -0
- package/src/watch/workflow-runs.ts +96 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from 'node:child_process'
|
|
2
|
+
import { watch } from 'node:fs'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { pendingKitUpdate, VERSION_POLL_MS } from '../version/installed-version'
|
|
6
|
+
import type { StartedKit } from '../version/started-kit'
|
|
7
|
+
import { CHILD_MARKER, exitCodeOf, isRestartTrigger, nextStep, RESTART_DEBOUNCE_MS } from './restart-decision'
|
|
8
|
+
import { TERMINAL_OFF } from './view'
|
|
9
|
+
|
|
10
|
+
const FORWARDED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const
|
|
11
|
+
|
|
12
|
+
const packageSourceDirectory = (): string => dirname(dirname(fileURLToPath(import.meta.url)))
|
|
13
|
+
|
|
14
|
+
const entryScript = (): string => {
|
|
15
|
+
const script = process.argv[1]
|
|
16
|
+
if (!script) throw new Error('Cannot supervise the plans view: process.argv[1] is missing')
|
|
17
|
+
return script
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const describeEnd = (code: number | null, signal: NodeJS.Signals | null): string =>
|
|
21
|
+
signal ? `signal ${signal}` : `exit ${code}`
|
|
22
|
+
|
|
23
|
+
const spawnView = (scriptPath: string, args: string[]): ChildProcess =>
|
|
24
|
+
spawn(process.execPath, [scriptPath, 'plans', ...args], {
|
|
25
|
+
stdio: 'inherit',
|
|
26
|
+
env: { ...process.env, [CHILD_MARKER]: '1' },
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Runs the live view as a child process and restarts it when the package's own source changes,
|
|
31
|
+
* so a long-lived `plans` never shows the behaviour of code that has since been edited. The child
|
|
32
|
+
* stops on SIGTERM through the handler that restores the terminal. Resolves with the exit code
|
|
33
|
+
* of a wanted end only: the child's clean exit (`q`) or a signal forwarded to it. A child that
|
|
34
|
+
* dies otherwise leaves the supervisor waiting, with the terminal restored, for the next change
|
|
35
|
+
* to `src/`, which spawns a fresh child. `kit` is the package this process loaded (null when it cannot be
|
|
36
|
+
* located, which turns the version check off). A failure to spawn the very first child resolves with 1.
|
|
37
|
+
*
|
|
38
|
+
* Meant to run once per process: the signal listeners it registers stay registered.
|
|
39
|
+
*/
|
|
40
|
+
export const superviseView = (
|
|
41
|
+
args: string[],
|
|
42
|
+
kit: StartedKit | null,
|
|
43
|
+
scriptPath: string = entryScript(),
|
|
44
|
+
): Promise<number> =>
|
|
45
|
+
new Promise((resolveExit) => {
|
|
46
|
+
let child: ChildProcess | undefined
|
|
47
|
+
let restarting = false
|
|
48
|
+
let stopping = false
|
|
49
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
50
|
+
let runningKit = kit
|
|
51
|
+
|
|
52
|
+
const finish = (code: number): void => {
|
|
53
|
+
clearTimeout(timer)
|
|
54
|
+
clearInterval(versionTimer)
|
|
55
|
+
watcher.close()
|
|
56
|
+
resolveExit(code)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const waitForChange = (reason: string): void => {
|
|
60
|
+
child = undefined
|
|
61
|
+
process.stdout.write(TERMINAL_OFF)
|
|
62
|
+
console.log(`plans view stopped (${reason}); waiting for the next change to src/`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const start = (isFirst: boolean): void => {
|
|
66
|
+
const current = spawnView(scriptPath, args)
|
|
67
|
+
child = current
|
|
68
|
+
|
|
69
|
+
current.on('error', (error) => {
|
|
70
|
+
if (child !== current) return
|
|
71
|
+
if (isFirst) {
|
|
72
|
+
console.error(`plans view could not start: ${error.message}`)
|
|
73
|
+
finish(1)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
console.error(error.message)
|
|
77
|
+
waitForChange('error')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
current.on('exit', (code, signal) => {
|
|
81
|
+
if (child !== current) return
|
|
82
|
+
const context = { restarting, stopping, code, signal }
|
|
83
|
+
restarting = false
|
|
84
|
+
const actions = {
|
|
85
|
+
respawn: () => start(false),
|
|
86
|
+
exit: () => finish(exitCodeOf(code, signal)),
|
|
87
|
+
wait: () => waitForChange(describeEnd(code, signal)),
|
|
88
|
+
}
|
|
89
|
+
actions[nextStep(context)]()
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const restartView = (): void => {
|
|
94
|
+
if (child) {
|
|
95
|
+
restarting = true
|
|
96
|
+
child.kill('SIGTERM')
|
|
97
|
+
} else start(false)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const watcher = watch(packageSourceDirectory(), { recursive: true }, (_event, name) => {
|
|
101
|
+
if (stopping || !name || !isRestartTrigger(name.toString())) return
|
|
102
|
+
clearTimeout(timer)
|
|
103
|
+
timer = setTimeout(restartView, RESTART_DEBOUNCE_MS)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const versionTimer = setInterval(() => {
|
|
107
|
+
const update = pendingKitUpdate(runningKit)
|
|
108
|
+
if (stopping || !update || !runningKit) return
|
|
109
|
+
runningKit = { ...runningKit, version: update.to }
|
|
110
|
+
restartView()
|
|
111
|
+
}, VERSION_POLL_MS)
|
|
112
|
+
|
|
113
|
+
start(true)
|
|
114
|
+
|
|
115
|
+
for (const signal of FORWARDED_SIGNALS) {
|
|
116
|
+
process.on(signal, () => {
|
|
117
|
+
stopping = true
|
|
118
|
+
clearTimeout(timer)
|
|
119
|
+
clearInterval(versionTimer)
|
|
120
|
+
if (child) child.kill(signal)
|
|
121
|
+
else finish(exitCodeOf(null, signal))
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { AGENT_STATUS, type AgentStatus, isAgentStatus } from '../terminal/types'
|
|
2
|
+
|
|
3
|
+
/** The symbols and words the plans views share: herdr's own status words, and the state of a phase. */
|
|
4
|
+
export const STATUS_SYMBOL: Record<AgentStatus, string> = {
|
|
5
|
+
[AGENT_STATUS.working]: '⚡',
|
|
6
|
+
[AGENT_STATUS.blocked]: '❓',
|
|
7
|
+
[AGENT_STATUS.idle]: '⏸',
|
|
8
|
+
[AGENT_STATUS.done]: '📨',
|
|
9
|
+
}
|
|
10
|
+
export const NO_AGENT_SYMBOL = '·'
|
|
11
|
+
export const NO_AGENT_WORD = 'no agent'
|
|
12
|
+
export const UNKNOWN_SYMBOL = '•'
|
|
13
|
+
|
|
14
|
+
export const statusSymbol = (agent: string | null): string => {
|
|
15
|
+
if (agent === null) return NO_AGENT_SYMBOL
|
|
16
|
+
return isAgentStatus(agent) ? STATUS_SYMBOL[agent] : UNKNOWN_SYMBOL
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const statusWord = (agent: string | null): string => agent ?? NO_AGENT_WORD
|
|
20
|
+
|
|
21
|
+
/** Before a row that opens and closes: lanes, the notes of a lane, the planned phases, the ticked items of a phase. */
|
|
22
|
+
export const CHEVRON = { closed: '▸', open: '▾' } as const
|
|
23
|
+
|
|
24
|
+
export const chevronOf = (open: boolean): string => (open ? CHEVRON.open : CHEVRON.closed)
|
|
25
|
+
|
|
26
|
+
export const PHASE_STATE = { missing: 'missing', empty: 'empty', all: 'all', open: 'open' } as const
|
|
27
|
+
|
|
28
|
+
export type PhaseState = (typeof PHASE_STATE)[keyof typeof PHASE_STATE]
|
|
29
|
+
|
|
30
|
+
/** A phase the plan lacks, one without checkboxes, one fully ticked, or one with ticks still to do. */
|
|
31
|
+
export const phaseStateOf = ({
|
|
32
|
+
title,
|
|
33
|
+
done,
|
|
34
|
+
total,
|
|
35
|
+
}: {
|
|
36
|
+
title: string | null
|
|
37
|
+
done: number
|
|
38
|
+
total: number
|
|
39
|
+
}): PhaseState => {
|
|
40
|
+
if (title === null) return PHASE_STATE.missing
|
|
41
|
+
if (total === 0) return PHASE_STATE.empty
|
|
42
|
+
return done === total ? PHASE_STATE.all : PHASE_STATE.open
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What a phase shows in place of a symbol: `0/5`, `✓ 15/15`, `–` without checkboxes, `?` when the plan lacks it. */
|
|
46
|
+
export const PHASE_COUNT = { missing: '?', empty: '–', allTicked: '✓' } as const
|
|
47
|
+
|
|
48
|
+
export const phaseCountOf = (phase: { title: string | null; done: number; total: number }): string => {
|
|
49
|
+
const state = phaseStateOf(phase)
|
|
50
|
+
if (state === PHASE_STATE.missing) return PHASE_COUNT.missing
|
|
51
|
+
if (state === PHASE_STATE.empty) return PHASE_COUNT.empty
|
|
52
|
+
const count = `${phase.done}/${phase.total}`
|
|
53
|
+
return state === PHASE_STATE.all ? `${PHASE_COUNT.allTicked} ${count}` : count
|
|
54
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
2
|
+
import { CHECKS, type ChecksState } from './pull-requests'
|
|
3
|
+
import { NO_AGENT_WORD, PHASE_COUNT } from './symbols'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Everything the plans view says in words but the watcher's badges. The view is English whatever language the
|
|
7
|
+
* project answers in; plan and phase titles come from the plans as written, and the status words are herdr's own terms.
|
|
8
|
+
*/
|
|
9
|
+
export type PlanTexts = {
|
|
10
|
+
noAgent: string
|
|
11
|
+
noPlan: string
|
|
12
|
+
noWorktree: string
|
|
13
|
+
noRow: string
|
|
14
|
+
notInPlan: string
|
|
15
|
+
noCheckboxes: string
|
|
16
|
+
ticked: string
|
|
17
|
+
tickedUnmerged: string
|
|
18
|
+
tickedSummary: (count: number) => string
|
|
19
|
+
notesHidden: (count: number) => string
|
|
20
|
+
notesShown: (count: number) => string
|
|
21
|
+
draft: string
|
|
22
|
+
privateLink: string
|
|
23
|
+
planned: (phases: number, plans: number) => string
|
|
24
|
+
following: (groups: string) => string
|
|
25
|
+
checks: Record<ChecksState, string>
|
|
26
|
+
hint: string
|
|
27
|
+
legend: string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The words of the watcher's badges and of the header notice when they are missing, in the project's response
|
|
32
|
+
* language (D96): they report on the lane's pull request as the watcher's own messages do.
|
|
33
|
+
*/
|
|
34
|
+
export type BadgeTexts = {
|
|
35
|
+
preview: string
|
|
36
|
+
/** The feedback since the last push, without ignored authors. */
|
|
37
|
+
feedback: (count: number) => string
|
|
38
|
+
changesRequested: string
|
|
39
|
+
watcherOff: string
|
|
40
|
+
stale: (minutes: number) => string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const BADGE_TEXTS: Record<PlanLanguage, BadgeTexts> = {
|
|
44
|
+
[PLAN_LANGUAGE.de]: {
|
|
45
|
+
preview: '🌐 preview',
|
|
46
|
+
feedback: (count) => `💬 ${count} neu`,
|
|
47
|
+
changesRequested: '✋ Änderungen verlangt',
|
|
48
|
+
watcherOff: 'Watcher aus',
|
|
49
|
+
stale: (minutes) => `Stand ${minutes} min alt`,
|
|
50
|
+
},
|
|
51
|
+
[PLAN_LANGUAGE.en]: {
|
|
52
|
+
preview: '🌐 preview',
|
|
53
|
+
feedback: (count) => `💬 ${count} new`,
|
|
54
|
+
changesRequested: '✋ changes requested',
|
|
55
|
+
watcherOff: 'watcher off',
|
|
56
|
+
stale: (minutes) => `state ${minutes} min old`,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What the view says about a board, in the word the project calls it (`board.term`). */
|
|
61
|
+
export const boardTexts = (term: string) => ({
|
|
62
|
+
missing: (glob: string) => `no ${term} under ${glob}`,
|
|
63
|
+
laneNotListed: (lane: string) => `lane "${lane}" is not in the ${term}`,
|
|
64
|
+
pageOpened: (path: string) => `${term} page opened: ${path}`,
|
|
65
|
+
pageNotOpened: (path: string, reason: string) => `${term} page written to ${path} (not opened: ${reason})`,
|
|
66
|
+
pageNotWritten: (reason: string) => `${term} page not written: ${reason}`,
|
|
67
|
+
pageNotOpenedReason: (reason: string) => `${term} page: ${reason}`,
|
|
68
|
+
notReadable: (reason: string) => `${term} not readable: ${reason}`,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
export const TEXTS: PlanTexts = {
|
|
72
|
+
noAgent: NO_AGENT_WORD,
|
|
73
|
+
noPlan: 'no plan',
|
|
74
|
+
noWorktree: 'no worktree',
|
|
75
|
+
noRow: 'no row in lanes',
|
|
76
|
+
notInPlan: 'not in the plan',
|
|
77
|
+
noCheckboxes: 'no checkboxes',
|
|
78
|
+
ticked: 'ticked',
|
|
79
|
+
tickedUnmerged: 'ticked, not merged',
|
|
80
|
+
tickedSummary: (count) => `${count} ticked · click or a lists them`,
|
|
81
|
+
notesHidden: (count) => `${count} ${count === 1 ? 'note' : 'notes'} hidden · click or n shows them`,
|
|
82
|
+
notesShown: (count) => `${count} ${count === 1 ? 'note' : 'notes'} shown · click or n hides them`,
|
|
83
|
+
draft: 'draft · ',
|
|
84
|
+
privateLink: 'private',
|
|
85
|
+
following: (groups) => `also open · ${groups}`,
|
|
86
|
+
planned: (phases, plans) =>
|
|
87
|
+
`planned (${phases} ${phases === 1 ? 'phase' : 'phases'} in ${plans} ${plans === 1 ? 'plan' : 'plans'})`,
|
|
88
|
+
checks: {
|
|
89
|
+
[CHECKS.passed]: '✓ checks',
|
|
90
|
+
[CHECKS.failed]: '✗ checks',
|
|
91
|
+
[CHECKS.pending]: '… checks',
|
|
92
|
+
[CHECKS.none]: 'no checks',
|
|
93
|
+
},
|
|
94
|
+
hint: '↑↓ select →← open a all n notes s html q quit ? help',
|
|
95
|
+
legend: [
|
|
96
|
+
'enter/space or click toggles a lane or a phase, click or enter on a task opens its whole text, and opens a ↗ pull request · wheel PgUp PgDn scroll · j k h l like the arrows · click or ctrl+click on a 📎 chip opens its link · ✦ artifact · ⑂ pull request or issue · ◈ Figma · private = the plan says so',
|
|
97
|
+
'⚡ working · ❓ blocked, waits for you · 📨 done = the agent finished its answer, not the lane · ⏸ idle · · no agent · working and blocked lanes open by themselves, → ← overrule until the status changes',
|
|
98
|
+
`n/m = m checkboxes of the phase, n ticked (the phase being worked on is yellow; ticked, not merged = all ticked in the worktree, not on the base branch yet) · ${PHASE_COUNT.allTicked} n/n all ticked · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · n shows or hides the notes of a lane · s opens every lane and plan as one HTML page · ⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main`,
|
|
99
|
+
],
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The mark of a running lane the board has no row for; null for a lane it lists. */
|
|
103
|
+
export const laneMarkText = (lane: { unlisted?: boolean }): string | null => (lane.unlisted ? TEXTS.noRow : null)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { isAbsolute, relative, sep } from 'node:path'
|
|
2
|
+
import type { SprintLane } from '../plan-format'
|
|
3
|
+
import type { LaneAgent } from '../terminal'
|
|
4
|
+
|
|
5
|
+
/** A worktree with an agent that has no row in the sprint table; `name` is its directory below the worktree directory. */
|
|
6
|
+
export type UnlistedLane = { name: string; agent: LaneAgent }
|
|
7
|
+
|
|
8
|
+
type PlacedAgent = { lane: string; agent: LaneAgent }
|
|
9
|
+
|
|
10
|
+
/** The worktree an agent runs in: the first directory below `worktreeDirectory`, null for anything outside it. */
|
|
11
|
+
export const laneDirectoryOf = (cwd: string, worktreeDirectory: string): string | null => {
|
|
12
|
+
const below = relative(worktreeDirectory, cwd)
|
|
13
|
+
if (below === '' || below === '..' || below.startsWith(`..${sep}`) || isAbsolute(below)) return null
|
|
14
|
+
return below.split(sep)[0]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** An unlisted lane as a sprint lane: its name and nothing else, no port, no steps. */
|
|
18
|
+
export const unlistedSprintLane = (name: string): SprintLane => ({ name, port: null, steps: [], closed: false })
|
|
19
|
+
|
|
20
|
+
const placed = (agents: readonly LaneAgent[], worktreeDirectory: string): PlacedAgent[] =>
|
|
21
|
+
agents.flatMap((agent) => {
|
|
22
|
+
const lane = agent.cwd ? laneDirectoryOf(agent.cwd, worktreeDirectory) : null
|
|
23
|
+
return lane === null ? [] : [{ lane, agent }]
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The lanes that run but are missing from the sprint table: one per worktree below `worktreeDirectory` with an
|
|
28
|
+
* agent in it. A worktree counts as listed when the table names it, or names any agent running in it. The first
|
|
29
|
+
* agent of a worktree stands for it; the lanes come in name order.
|
|
30
|
+
*/
|
|
31
|
+
export const unlistedLanes = (
|
|
32
|
+
agents: readonly LaneAgent[],
|
|
33
|
+
listed: ReadonlySet<string>,
|
|
34
|
+
worktreeDirectory: string,
|
|
35
|
+
): UnlistedLane[] => {
|
|
36
|
+
const inWorktrees = placed(agents, worktreeDirectory)
|
|
37
|
+
const listedWorktrees = new Set(
|
|
38
|
+
inWorktrees.filter(({ lane, agent }) => listed.has(lane) || listed.has(agent.name)).map(({ lane }) => lane),
|
|
39
|
+
)
|
|
40
|
+
const byLane = new Map<string, LaneAgent>()
|
|
41
|
+
for (const { lane, agent } of inWorktrees) {
|
|
42
|
+
if (!listedWorktrees.has(lane) && !byLane.has(lane)) byLane.set(lane, agent)
|
|
43
|
+
}
|
|
44
|
+
return [...byLane].sort(([left], [right]) => left.localeCompare(right)).map(([name, agent]) => ({ name, agent }))
|
|
45
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { fit, plainText } from '../terminal/text'
|
|
2
|
+
import { renderSelected, type Screen } from './layout'
|
|
3
|
+
import type { Options } from './options'
|
|
4
|
+
import { laneSelection, overviewSelection, type Selection } from './selection'
|
|
5
|
+
import type { Snapshot } from './snapshot'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_WIDTH = 100
|
|
8
|
+
const CURSOR_HOME = '\x1b[H'
|
|
9
|
+
const CLEAR_LINE_END = '\x1b[K'
|
|
10
|
+
const CLEAR_BELOW = '\x1b[J'
|
|
11
|
+
|
|
12
|
+
export const TERMINAL_ON = '\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h'
|
|
13
|
+
export const TERMINAL_OFF = '\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l'
|
|
14
|
+
|
|
15
|
+
/** One column short of the pane: a line filling the last column makes some terminals wrap it. */
|
|
16
|
+
export const paneWidth = (): number => (process.stdout.columns ?? (Number(process.env.COLUMNS) || DEFAULT_WIDTH)) - 1
|
|
17
|
+
|
|
18
|
+
const paneRows = (): number => process.stdout.rows ?? Number.MAX_SAFE_INTEGER
|
|
19
|
+
|
|
20
|
+
export const bodyRows = (screen: Screen): number =>
|
|
21
|
+
Math.max(paneRows() - screen.header.length - screen.footer.length, 1)
|
|
22
|
+
|
|
23
|
+
export const clockOf = (locale: string): string => new Date().toLocaleTimeString(locale)
|
|
24
|
+
|
|
25
|
+
export const renderSnapshot = (
|
|
26
|
+
snapshot: Snapshot,
|
|
27
|
+
selection: Selection,
|
|
28
|
+
errors: string[],
|
|
29
|
+
width: number,
|
|
30
|
+
locale: string,
|
|
31
|
+
): Screen => {
|
|
32
|
+
const screen = renderSelected(
|
|
33
|
+
{
|
|
34
|
+
title: snapshot.title,
|
|
35
|
+
time: clockOf(locale),
|
|
36
|
+
lanes: snapshot.views,
|
|
37
|
+
planned: snapshot.planned,
|
|
38
|
+
watch: snapshot.watch,
|
|
39
|
+
language: snapshot.language,
|
|
40
|
+
},
|
|
41
|
+
selection,
|
|
42
|
+
width,
|
|
43
|
+
)
|
|
44
|
+
return { ...screen, footer: [...errors.map((error) => fit(error, width)), ...screen.footer] }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const startSelection = (snapshot: Snapshot, options: Options): Selection =>
|
|
48
|
+
options.lane
|
|
49
|
+
? laneSelection(snapshot.views[0]?.progress, snapshot.views[0]?.status.agent ?? null)
|
|
50
|
+
: overviewSelection()
|
|
51
|
+
|
|
52
|
+
/** Header and footer stay put; the body shows `bodyRows` lines from `top`, padded so the footer sits at the bottom. */
|
|
53
|
+
export const draw = (screen: Screen, top: number): void => {
|
|
54
|
+
const width = paneWidth()
|
|
55
|
+
const rows = bodyRows(screen)
|
|
56
|
+
const body = screen.body.lines.slice(top, top + rows)
|
|
57
|
+
const padding = Array.from({ length: Math.max(rows - body.length, 0) }, () => '')
|
|
58
|
+
const lines = [...screen.header, ...body, ...padding, ...screen.footer]
|
|
59
|
+
process.stdout.write(
|
|
60
|
+
`${CURSOR_HOME}${lines.map((line) => `${fit(line, width)}${CLEAR_LINE_END}`).join('\r\n')}${CLEAR_BELOW}`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One frame as text; ANSI styling is dropped when stdout is not a terminal. */
|
|
65
|
+
export const frameText = (screen: Screen, isTerminal: boolean): string => {
|
|
66
|
+
const lines = [...screen.header, ...screen.body.lines, ...screen.footer]
|
|
67
|
+
return (isTerminal ? lines : lines.map(plainText)).join('\n')
|
|
68
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type LaneProgress,
|
|
3
|
+
laneProgress,
|
|
4
|
+
PHASE_ID_PREFIX,
|
|
5
|
+
type PlanStep,
|
|
6
|
+
type PlansByHref,
|
|
7
|
+
type SprintLane,
|
|
8
|
+
} from '../plan-format'
|
|
9
|
+
import { belongsToLane } from './phase-source'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The phases of a plan the lane worked on in its worktree although none of its rows names them, in plan order: touched
|
|
13
|
+
* by `mergePlanPhases` and with at least one tick, so a draft the lane only wrote down does not become its current phase.
|
|
14
|
+
*/
|
|
15
|
+
const workedIds = (lane: SprintLane, plans: PlansByHref, href: string): string[] => {
|
|
16
|
+
const named = lane.steps.filter((step) => step.planHref === href).flatMap((step) => step.phaseIds)
|
|
17
|
+
return (plans[href] ?? [])
|
|
18
|
+
.filter((phase) => phase.touched && phase.items.some((item) => item.done) && !belongsToLane(phase.id, named))
|
|
19
|
+
.map((phase) => phase.id)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A step no board row wrote: the plan of `last`, the worked phases, no model and no start condition. */
|
|
23
|
+
const workedStep = (last: PlanStep, ids: string[]): PlanStep => ({
|
|
24
|
+
planTitle: last.planTitle,
|
|
25
|
+
planHref: last.planHref,
|
|
26
|
+
phaseIds: ids,
|
|
27
|
+
label: ids.map((id) => `${PHASE_ID_PREFIX}${id}`).join(' '),
|
|
28
|
+
startsAfter: '',
|
|
29
|
+
model: '',
|
|
30
|
+
withoutRow: true,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The lane with one more step per plan for the phases it worked on without a board row: `mergePlanPhases` marks
|
|
35
|
+
* them touched (new in the worktree, or ticked further than the base). Marked `withoutRow`, they count as older phases
|
|
36
|
+
* when `currentPhaseOf` looks for the lane's newest board row, and they leave the list of phases still open in the plan.
|
|
37
|
+
*/
|
|
38
|
+
export const withWorkedPhases = (lane: SprintLane, plans: PlansByHref): SprintLane => {
|
|
39
|
+
const hrefs = [...new Set(lane.steps.flatMap((step) => (step.planHref ? [step.planHref] : [])))]
|
|
40
|
+
const added = hrefs.flatMap((href) => {
|
|
41
|
+
const ids = workedIds(lane, plans, href)
|
|
42
|
+
const last = lane.steps.filter((step) => step.planHref === href).at(-1)
|
|
43
|
+
return last && ids.length > 0 ? [workedStep(last, ids)] : []
|
|
44
|
+
})
|
|
45
|
+
return added.length > 0 ? { ...lane, steps: [...lane.steps, ...added] } : lane
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The progress of a lane over its rows and the phases it worked on without one, as the watcher reports it. The view
|
|
50
|
+
* builds the same from `withWorkedPhases` itself, since it also needs that lane for the phases still open.
|
|
51
|
+
*/
|
|
52
|
+
export const workedLaneProgress = (lane: SprintLane, plans: PlansByHref): LaneProgress =>
|
|
53
|
+
laneProgress(withWorkedPhases(lane, plans), plans)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export const checksum = (content: string): string => createHash('sha256').update(content, 'utf8').digest('hex')
|
|
4
|
+
|
|
5
|
+
const sortKeys = (value: unknown): unknown => {
|
|
6
|
+
if (Array.isArray(value)) return value.map(sortKeys)
|
|
7
|
+
if (value === null || typeof value !== 'object') return value
|
|
8
|
+
const entries = Object.entries(value).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
9
|
+
return Object.fromEntries(entries.map(([key, inner]) => [key, sortKeys(inner)]))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** JSON with sorted keys, so two semantically equal objects serialise to the same string. */
|
|
13
|
+
export const canonicalJson = (value: unknown): string => JSON.stringify(sortKeys(value))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** One answer of a `choose` question: the value the code gets and the words the user reads. */
|
|
2
|
+
export type Choice<T extends string> = { value: T; label: string }
|
|
3
|
+
|
|
4
|
+
/** The text of a `choose` question: the question, then one numbered line per answer, the fallback marked. */
|
|
5
|
+
export const choiceText = <T extends string>(question: string, options: readonly Choice<T>[], fallback: T): string =>
|
|
6
|
+
[
|
|
7
|
+
question,
|
|
8
|
+
...options.map(({ value, label }, index) => ` ${index + 1}) ${label}${value === fallback ? ' (default)' : ''}`),
|
|
9
|
+
`Choose 1-${options.length}, Enter for the default: `,
|
|
10
|
+
].join('\n')
|
|
11
|
+
|
|
12
|
+
/** The chosen value: a valid number picks its answer, anything else (Enter included) the fallback. */
|
|
13
|
+
export const chosenValue = <T extends string>(answer: string, options: readonly Choice<T>[], fallback: T): T =>
|
|
14
|
+
options[Number.parseInt(answer, 10) - 1]?.value ?? fallback
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { createInterface } from 'node:readline/promises'
|
|
4
|
+
import { CLI_NAME } from '../version/release'
|
|
5
|
+
import { type Choice, choiceText, chosenValue } from './choice'
|
|
6
|
+
import { errorMessage } from './error-message'
|
|
7
|
+
import { linkHerdrPluginFrom } from './herdr-plugin'
|
|
8
|
+
import type { KitSource } from './kit'
|
|
9
|
+
import { type KitLocation, locateKit, readKitSource } from './kit-source'
|
|
10
|
+
import { exitCodeOf, formatReport, SYNC_EXIT } from './report'
|
|
11
|
+
import type { SyncResult } from './sync-result'
|
|
12
|
+
import { detectTools, INSTALLERS, type InstallerName, spawnTool, type ToolPresence } from './tools'
|
|
13
|
+
|
|
14
|
+
export type CommandContext = {
|
|
15
|
+
projectRoot: string
|
|
16
|
+
/** `XDG_CONFIG_HOME` from the environment; a test context leaves it out, so its home stays its own. */
|
|
17
|
+
configHome?: string
|
|
18
|
+
/** Where `--global` installs: the directory holding the user's `.claude/`. */
|
|
19
|
+
home: string
|
|
20
|
+
locate: () => Promise<KitLocation>
|
|
21
|
+
log: (line: string) => void
|
|
22
|
+
/** Hooks the herdr plugin in and returns the line to print, if any. Must not throw: a failure is a line, not an error. */
|
|
23
|
+
linkHerdrPlugin: (packageRoot: string) => string | null
|
|
24
|
+
/** A yes/no question to the user; null when there is no terminal to ask in. */
|
|
25
|
+
ask: (question: string) => Promise<boolean | null>
|
|
26
|
+
/** A choice among fixed answers; Enter takes `fallback`; null when there is no terminal to ask in. */
|
|
27
|
+
choose: <T extends string>(question: string, options: readonly Choice<T>[], fallback: T) => Promise<T | null>
|
|
28
|
+
/** Which external tools are installed; the test context answers a fixed value, so no test depends on the machine. */
|
|
29
|
+
detectTools: () => ToolPresence
|
|
30
|
+
/** True when `jh` runs from the PATH, so a hook may name it; the test context answers a fixed value. */
|
|
31
|
+
cliOnPath: () => boolean
|
|
32
|
+
/** Runs one of the kit's fixed installers with the terminal attached; the exit code. */
|
|
33
|
+
runInstaller: (name: InstallerName) => number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What a command did: lines to print before the report, and the sync result. */
|
|
37
|
+
export type CommandOutcome = { notes: string[]; result: SyncResult }
|
|
38
|
+
|
|
39
|
+
const YES = /^y(es)?$/i
|
|
40
|
+
|
|
41
|
+
const hasTerminal = (): boolean => Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
42
|
+
|
|
43
|
+
/** One question on the terminal, the trimmed answer. */
|
|
44
|
+
const questionOnTerminal = async (text: string): Promise<string> => {
|
|
45
|
+
const terminal = createInterface({ input: process.stdin, output: process.stdout })
|
|
46
|
+
try {
|
|
47
|
+
return (await terminal.question(text)).trim()
|
|
48
|
+
} finally {
|
|
49
|
+
terminal.close()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Asks on the terminal; without one on both stdin and stdout nobody would see or answer the question. */
|
|
54
|
+
const askOnTerminal = async (question: string): Promise<boolean | null> =>
|
|
55
|
+
hasTerminal() ? YES.test(await questionOnTerminal(`${question} [y/N] `)) : null
|
|
56
|
+
|
|
57
|
+
const chooseOnTerminal = async <T extends string>(
|
|
58
|
+
question: string,
|
|
59
|
+
options: readonly Choice<T>[],
|
|
60
|
+
fallback: T,
|
|
61
|
+
): Promise<T | null> =>
|
|
62
|
+
hasTerminal()
|
|
63
|
+
? chosenValue(await questionOnTerminal(choiceText(question, options, fallback)), options, fallback)
|
|
64
|
+
: null
|
|
65
|
+
|
|
66
|
+
/** One of `INSTALLERS`, run by `sh` from the home directory with the terminal attached; the name type admits no other command. */
|
|
67
|
+
const runInstallerOnTerminal = (name: InstallerName): number =>
|
|
68
|
+
spawnSync('sh', ['-c', INSTALLERS[name]], { stdio: 'inherit', cwd: homedir() }).status ?? 1
|
|
69
|
+
|
|
70
|
+
export const defaultContext = (): CommandContext => ({
|
|
71
|
+
projectRoot: process.cwd(),
|
|
72
|
+
home: homedir(),
|
|
73
|
+
configHome: process.env.XDG_CONFIG_HOME,
|
|
74
|
+
locate: () => locateKit(),
|
|
75
|
+
log: (line) => console.log(line),
|
|
76
|
+
linkHerdrPlugin: linkHerdrPluginFrom,
|
|
77
|
+
ask: askOnTerminal,
|
|
78
|
+
choose: chooseOnTerminal,
|
|
79
|
+
detectTools: () => detectTools(process.env, process.platform, spawnTool(process.env)),
|
|
80
|
+
cliOnPath: () => !spawnTool(process.env)(CLI_NAME, ['--version']).missing,
|
|
81
|
+
runInstaller: runInstallerOnTerminal,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
export const usageError = (context: CommandContext, message: string): number => {
|
|
85
|
+
context.log(message)
|
|
86
|
+
return SYNC_EXIT.error
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Reads the kit, runs the command, prints the report; any thrown error becomes exit code 2. */
|
|
90
|
+
export const runWithKit = async (
|
|
91
|
+
context: CommandContext,
|
|
92
|
+
commandName: string,
|
|
93
|
+
action: (kit: KitSource) => Promise<CommandOutcome>,
|
|
94
|
+
): Promise<number> => {
|
|
95
|
+
try {
|
|
96
|
+
const location = await context.locate()
|
|
97
|
+
const { notes, result } = await action(await readKitSource(location))
|
|
98
|
+
const herdrNote = context.linkHerdrPlugin(location.packageRoot)
|
|
99
|
+
for (const note of herdrNote ? [...notes, herdrNote] : notes) context.log(note)
|
|
100
|
+
context.log(formatReport(result))
|
|
101
|
+
return exitCodeOf(result)
|
|
102
|
+
} catch (err) {
|
|
103
|
+
context.log(`${CLI_NAME} ${commandName} failed: ${errorMessage(err)}`)
|
|
104
|
+
return SYNC_EXIT.error
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Installs into `~/.claude/` instead of the project's `.claude/`. */
|
|
109
|
+
export const GLOBAL_FLAG = '--global'
|
|
110
|
+
|
|
111
|
+
/** Printed before a global report: its paths are relative to the home directory, not to the project. */
|
|
112
|
+
export const globalTargetNote = (home: string): string =>
|
|
113
|
+
`Installed into ~/.claude; the paths below are relative to ${home}.`
|