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,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
|
+
}
|