jackherd 0.0.0-stage → 1.0.0
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 +61 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +1123 -2
- 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 +65 -4
- 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,239 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { CLAUDE_DIRECTORY, SETTINGS_FILE } from '../claude/paths'
|
|
4
|
+
import { configCandidates, globalConfigPath, PROJECT_CONFIG_PATH } from '../config/location'
|
|
5
|
+
import { CLI_NAME, PACKAGE_NAME } from '../version/release'
|
|
6
|
+
import {
|
|
7
|
+
type CommandContext,
|
|
8
|
+
defaultContext,
|
|
9
|
+
GLOBAL_FLAG,
|
|
10
|
+
globalTargetNote,
|
|
11
|
+
runWithKit,
|
|
12
|
+
usageError,
|
|
13
|
+
} from './command-context'
|
|
14
|
+
import { collidingNames, DEFAULT_SKILL_PREFIX, type InitResult, runGlobalInit, runInit } from './init'
|
|
15
|
+
import type { KitSource } from './kit'
|
|
16
|
+
import { nextSteps } from './next-steps'
|
|
17
|
+
import { SYNC_EXIT } from './report'
|
|
18
|
+
import { GLOBAL_STATUS_LINE_COMMAND, migrateGlobalSettings } from './settings-migration'
|
|
19
|
+
import {
|
|
20
|
+
applySettingsOffer,
|
|
21
|
+
readSettingsOffer,
|
|
22
|
+
SESSION_HOOK_COMMAND,
|
|
23
|
+
SESSION_HOOK_OFFER,
|
|
24
|
+
type SettingsOffer,
|
|
25
|
+
type SettingsRead,
|
|
26
|
+
statusLineOffer,
|
|
27
|
+
} from './settings-offer'
|
|
28
|
+
import type { SetupOutcome } from './setup'
|
|
29
|
+
import { chooseSetup } from './setup-questions'
|
|
30
|
+
|
|
31
|
+
/** The answer to init's questions (prefix, hook, status line), for runs without a terminal. */
|
|
32
|
+
const ANSWER_FLAG = { yes: '--yes', no: '--no' } as const
|
|
33
|
+
|
|
34
|
+
/** Where a project's status line runs from: the copy `sync` writes into its `.claude/`. */
|
|
35
|
+
const PROJECT_STATUS_LINE = `${CLAUDE_DIRECTORY}/statusline.sh`
|
|
36
|
+
|
|
37
|
+
const INIT_USAGE = `Usage: ${CLI_NAME} init [--global] [${ANSWER_FLAG.yes} | ${ANSWER_FLAG.no}]`
|
|
38
|
+
|
|
39
|
+
/** How the user runs the CLI after each kind of install, for the next steps. */
|
|
40
|
+
const GLOBAL_CLI = CLI_NAME
|
|
41
|
+
const PROJECT_CLI = `bunx ${PACKAGE_NAME}`
|
|
42
|
+
|
|
43
|
+
const NO_PREFIX = { skillPrefix: undefined, notes: [] }
|
|
44
|
+
|
|
45
|
+
/** What a successful init leaves for its next steps: the config's names and the kit's version. */
|
|
46
|
+
type FinishedInit = { init: InitResult; version: string }
|
|
47
|
+
|
|
48
|
+
type InitArgs = { ok: true; global: boolean; answer: boolean | undefined } | { ok: false; message: string }
|
|
49
|
+
|
|
50
|
+
/** `--yes` → true, `--no` → false, neither → undefined (ask). */
|
|
51
|
+
const answerOf = (yes: boolean, no: boolean): boolean | undefined => {
|
|
52
|
+
if (yes) return true
|
|
53
|
+
if (no) return false
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const parseInitArgs = (args: string[]): InitArgs => {
|
|
58
|
+
const unknown = args.find((arg) => arg !== GLOBAL_FLAG && arg !== ANSWER_FLAG.yes && arg !== ANSWER_FLAG.no)
|
|
59
|
+
if (unknown !== undefined) return { ok: false, message: `Unknown argument: ${unknown}\n${INIT_USAGE}` }
|
|
60
|
+
const [yes, no, global] = [ANSWER_FLAG.yes, ANSWER_FLAG.no, GLOBAL_FLAG].map((flag) => args.includes(flag))
|
|
61
|
+
if (yes && no)
|
|
62
|
+
return { ok: false, message: `${ANSWER_FLAG.yes} and ${ANSWER_FLAG.no} exclude each other\n${INIT_USAGE}` }
|
|
63
|
+
return { ok: true, global, answer: answerOf(yes, no) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const configNote = (configCreated: boolean, path: string): string =>
|
|
67
|
+
configCreated ? `Created ${path}.` : `Found ${path}, kept it as it is.`
|
|
68
|
+
|
|
69
|
+
/** The lines about the config: created or kept, and for a new one the defaults taken without asking. */
|
|
70
|
+
const configNotes = (configCreated: boolean, path: string, setup: SetupOutcome | null): string[] => [
|
|
71
|
+
configNote(configCreated, path),
|
|
72
|
+
...(configCreated && setup?.note ? [setup.note] : []),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
const HOOK_QUESTION = `Add a SessionStart hook that runs \`${SESSION_HOOK_COMMAND}\`, so every new session brings the global kit up to date after an update?`
|
|
76
|
+
|
|
77
|
+
const STATUS_LINE_QUESTION = "Show the kit's status line (model, context fill, session cost)?"
|
|
78
|
+
|
|
79
|
+
/** One setting `init` offers: what it writes, where, and how it asks. */
|
|
80
|
+
type Offer = { offer: SettingsOffer; path: string; shownPath: string; label: string; question: string }
|
|
81
|
+
|
|
82
|
+
const sessionHookOffer = (home: string): Offer => ({
|
|
83
|
+
offer: SESSION_HOOK_OFFER,
|
|
84
|
+
path: join(home, CLAUDE_DIRECTORY, SETTINGS_FILE),
|
|
85
|
+
shownPath: `~/${CLAUDE_DIRECTORY}/${SETTINGS_FILE}`,
|
|
86
|
+
label: 'the SessionStart hook',
|
|
87
|
+
question: HOOK_QUESTION,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const statusLineOfferAt = (root: string, shownRoot: string, command: string): Offer => ({
|
|
91
|
+
offer: statusLineOffer(command),
|
|
92
|
+
path: join(root, CLAUDE_DIRECTORY, SETTINGS_FILE),
|
|
93
|
+
shownPath: `${shownRoot}${CLAUDE_DIRECTORY}/${SETTINGS_FILE}`,
|
|
94
|
+
label: 'the status line',
|
|
95
|
+
question: STATUS_LINE_QUESTION,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const notWritten = (offer: Offer, reason: string): string =>
|
|
99
|
+
`Did not add ${offer.label} to ${offer.shownPath} (${reason}). It stays as it was; the README shows the entry to add by hand.`
|
|
100
|
+
|
|
101
|
+
const notAsked = (offer: Offer): string =>
|
|
102
|
+
`Not asked about ${offer.label} without a terminal: \`${ANSWER_FLAG.yes}\` adds it to ${offer.shownPath}, \`${ANSWER_FLAG.no}\` leaves it out.`
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A status line of the user's own is replaced only after a yes to the question that names it: `--yes` does not
|
|
106
|
+
* answer it, `--no` does, and without a terminal it is not asked.
|
|
107
|
+
*/
|
|
108
|
+
const foreignAnswer = async (
|
|
109
|
+
context: CommandContext,
|
|
110
|
+
offer: Offer,
|
|
111
|
+
current: string,
|
|
112
|
+
answer: boolean | undefined,
|
|
113
|
+
): Promise<boolean | null> => {
|
|
114
|
+
if (answer === false) return false
|
|
115
|
+
return context.ask(`${offer.shownPath} already runs \`${current}\`. Replace it with ${offer.label} of the kit?`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The answer for one offer: from the command line, else from the user, null when nobody can be asked. */
|
|
119
|
+
const answerFor = async (context: CommandContext, offer: Offer, read: SettingsRead, answer: boolean | undefined) => {
|
|
120
|
+
if (read.plan.kind === 'foreign') return foreignAnswer(context, offer, read.plan.current, answer)
|
|
121
|
+
return (
|
|
122
|
+
answer ??
|
|
123
|
+
(await context.ask(`${offer.question} It goes into ${offer.shownPath}; everything else there stays as it is.`))
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Asks once per offer unless it is there already or the command line answered; never fails the init. */
|
|
128
|
+
const offerSetting = async (context: CommandContext, offer: Offer, answer: boolean | undefined): Promise<void> => {
|
|
129
|
+
const read = await readSettingsOffer(offer.path, offer.offer)
|
|
130
|
+
if (read.plan.kind === 'present') return context.log(`${offer.shownPath} already has ${offer.label}.`)
|
|
131
|
+
if (read.plan.kind === 'unreadable') return context.log(notWritten(offer, read.plan.reason))
|
|
132
|
+
const choice = await answerFor(context, offer, read, answer)
|
|
133
|
+
if (choice === null) return context.log(notAsked(offer))
|
|
134
|
+
if (!choice) {
|
|
135
|
+
const kept = read.plan.kind === 'foreign' ? `, kept \`${read.plan.current}\`` : ''
|
|
136
|
+
return context.log(`Left ${offer.label} out of ${offer.shownPath}${kept}.`)
|
|
137
|
+
}
|
|
138
|
+
const written = await applySettingsOffer(offer.path, read)
|
|
139
|
+
context.log(
|
|
140
|
+
written.kind === 'written' ? `Added ${offer.label} to ${offer.shownPath}.` : notWritten(offer, written.reason),
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const offerSettings = async (context: CommandContext, offers: Offer[], answer: boolean | undefined) => {
|
|
145
|
+
for (const offer of offers) await offerSetting(context, offer, answer)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const prefixQuestion = (names: string[]): string =>
|
|
149
|
+
`This project already has ${names.join(', ')}. Install the kit's skills and agents with the prefix ${DEFAULT_SKILL_PREFIX} (/${DEFAULT_SKILL_PREFIX}implement, …), so yours stay as they are and the kit's skills call each other, not yours?`
|
|
150
|
+
|
|
151
|
+
const prefixNotAsked = (names: string[]): string =>
|
|
152
|
+
`Not asked about a prefix without a terminal although the project has ${names.join(', ')}: those became conflicts (.kit-new). \`${CLI_NAME} init ${ANSWER_FLAG.yes}\` on a fresh config installs the kit as ${DEFAULT_SKILL_PREFIX}…; or set "skillPrefix" in ${PROJECT_CONFIG_PATH} and run sync.`
|
|
153
|
+
|
|
154
|
+
/** The prefix for a new config: asked when the project already has skills or agents of kit names. */
|
|
155
|
+
const chooseSkillPrefix = async (context: CommandContext, kit: KitSource, answer: boolean | undefined) => {
|
|
156
|
+
const claudeDirectory = join(context.projectRoot, CLAUDE_DIRECTORY)
|
|
157
|
+
const names = collidingNames(kit, (path) => existsSync(join(claudeDirectory, path)))
|
|
158
|
+
if (names.length === 0) return { skillPrefix: undefined, notes: [] }
|
|
159
|
+
const choice = answer ?? (await context.ask(prefixQuestion(names)))
|
|
160
|
+
if (choice === null) return { skillPrefix: undefined, notes: [prefixNotAsked(names)] }
|
|
161
|
+
return { skillPrefix: choice ? DEFAULT_SKILL_PREFIX : undefined, notes: [] }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Prints what to do next, after everything else init said; tools are detected again, since setup may have installed herdr. */
|
|
165
|
+
const printNextSteps = (context: CommandContext, finished: FinishedInit, cli: string): void => {
|
|
166
|
+
const steps = nextSteps({
|
|
167
|
+
skillPrefix: finished.init.skillPrefix,
|
|
168
|
+
adapter: finished.init.terminalAdapter,
|
|
169
|
+
tools: context.detectTools(),
|
|
170
|
+
version: finished.version,
|
|
171
|
+
cli,
|
|
172
|
+
})
|
|
173
|
+
for (const line of steps) context.log(line)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** After the sync: the settings offers, then the next steps; nothing of it when init failed. */
|
|
177
|
+
const finishInit = async (
|
|
178
|
+
context: CommandContext,
|
|
179
|
+
exitCode: number,
|
|
180
|
+
finished: FinishedInit | undefined,
|
|
181
|
+
after: { offers: Offer[]; cli: string; answer: boolean | undefined },
|
|
182
|
+
): Promise<number> => {
|
|
183
|
+
if (exitCode === SYNC_EXIT.error || !finished) return exitCode
|
|
184
|
+
await offerSettings(context, after.offers, after.answer)
|
|
185
|
+
printNextSteps(context, finished, after.cli)
|
|
186
|
+
return exitCode
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const initProjectCommand = async (context: CommandContext, answer: boolean | undefined): Promise<number> => {
|
|
190
|
+
const configExists = configCandidates(context.projectRoot).some(existsSync)
|
|
191
|
+
let finished: FinishedInit | undefined
|
|
192
|
+
const exitCode = await runWithKit(context, 'init', async (kit) => {
|
|
193
|
+
const { skillPrefix, notes } = configExists ? NO_PREFIX : await chooseSkillPrefix(context, kit, answer)
|
|
194
|
+
const setup = configExists ? null : await chooseSetup(context, answer)
|
|
195
|
+
const init = await runInit({
|
|
196
|
+
projectRoot: context.projectRoot,
|
|
197
|
+
home: context.home,
|
|
198
|
+
kit,
|
|
199
|
+
skillPrefix,
|
|
200
|
+
setup: setup?.values,
|
|
201
|
+
})
|
|
202
|
+
finished = { init, version: kit.version }
|
|
203
|
+
return {
|
|
204
|
+
notes: [...configNotes(init.configCreated, PROJECT_CONFIG_PATH, setup), ...notes],
|
|
205
|
+
result: init.sync,
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
const offers = [statusLineOfferAt(context.projectRoot, '', PROJECT_STATUS_LINE)]
|
|
209
|
+
return finishInit(context, exitCode, finished, { offers, cli: PROJECT_CLI, answer })
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const initGlobalCommand = async (context: CommandContext, answer: boolean | undefined): Promise<number> => {
|
|
213
|
+
const configExists = configCandidates(context.home).some(existsSync)
|
|
214
|
+
let finished: FinishedInit | undefined
|
|
215
|
+
const exitCode = await runWithKit(context, 'init', async (kit) => {
|
|
216
|
+
const setup = configExists ? null : await chooseSetup(context, answer)
|
|
217
|
+
const init = await runGlobalInit({ home: context.home, kit, setup: setup?.values, configHome: context.configHome })
|
|
218
|
+
finished = { init, version: kit.version }
|
|
219
|
+
const settings = await migrateGlobalSettings(context.home, context.cliOnPath())
|
|
220
|
+
return {
|
|
221
|
+
notes: [
|
|
222
|
+
...configNotes(init.configCreated, globalConfigPath(context.home), setup),
|
|
223
|
+
globalTargetNote(context.home),
|
|
224
|
+
...settings,
|
|
225
|
+
],
|
|
226
|
+
result: init.sync,
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
const offers = [sessionHookOffer(context.home), statusLineOfferAt(context.home, '~/', GLOBAL_STATUS_LINE_COMMAND)]
|
|
230
|
+
return finishInit(context, exitCode, finished, { offers, cli: GLOBAL_CLI, answer })
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export const runInitCommand = async (args: string[], context: CommandContext = defaultContext()): Promise<number> => {
|
|
234
|
+
const parsed = parseInitArgs(args)
|
|
235
|
+
if (!parsed.ok) return usageError(context, parsed.message)
|
|
236
|
+
return parsed.global ? initGlobalCommand(context, parsed.answer) : initProjectCommand(context, parsed.answer)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export const run = (args: string[]): Promise<number> => runInitCommand(args)
|
package/src/sync/init.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { AGENTS_DIRECTORY, SKILLS_DIRECTORY } from '../claude/paths'
|
|
4
|
+
import { type JackherdConfig, MODEL_ROLE, type ModelRole, parseJackherdConfig, type TerminalAdapter } from '../config'
|
|
5
|
+
import { type RepositoryFacts, withDetected } from '../config/detect'
|
|
6
|
+
import { CONFIG_FILE, CONFIG_SCHEMA_REFERENCE, TOOL_DIRECTORY } from '../config/location'
|
|
7
|
+
import { readRepositoryFacts } from '../config/repository-facts'
|
|
8
|
+
import type { KitSource } from './kit'
|
|
9
|
+
import { moveGlobalLegacyFiles, moveProjectLegacyFiles } from './legacy-files'
|
|
10
|
+
import { assertNotHomeDirectory, assertOwnDirectoriesInProject, createFileExclusively } from './project-fs'
|
|
11
|
+
import type { SetupConfig } from './setup'
|
|
12
|
+
import { syncGlobalWithConfig, syncProjectWithConfig } from './sync'
|
|
13
|
+
import type { SyncResult } from './sync-result'
|
|
14
|
+
|
|
15
|
+
/** `home` defaults to the real home directory, which is never a project; `facts` to the project root's. */
|
|
16
|
+
export type InitRequest = {
|
|
17
|
+
projectRoot: string
|
|
18
|
+
home?: string
|
|
19
|
+
kit: KitSource
|
|
20
|
+
facts?: RepositoryFacts
|
|
21
|
+
/** Written into a new config, so the kit's skills and agents stay apart from the project's own. */
|
|
22
|
+
skillPrefix?: string
|
|
23
|
+
/** The languages and the terminal adapter for a new config; the schema's defaults without it. */
|
|
24
|
+
setup?: SetupConfig
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `configHome` is `XDG_CONFIG_HOME` as the command read it; undefined for `~/.config`. */
|
|
28
|
+
export type GlobalInitRequest = { home: string; kit: KitSource; setup?: SetupConfig; configHome?: string }
|
|
29
|
+
|
|
30
|
+
/** `skillPrefix` and `terminalAdapter` are the config's as read, so an existing config yields its own names. */
|
|
31
|
+
export type InitResult = {
|
|
32
|
+
configCreated: boolean
|
|
33
|
+
sync: SyncResult
|
|
34
|
+
skillPrefix: string
|
|
35
|
+
terminalAdapter: TerminalAdapter
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// TODO: default model per role for a fresh project; the role list
|
|
39
|
+
// itself is still a draft in config/schema.ts.
|
|
40
|
+
export const DEFAULT_MODELS: Record<ModelRole, string> = {
|
|
41
|
+
[MODEL_ROLE.plan]: 'claude-opus-5-5',
|
|
42
|
+
[MODEL_ROLE.implement]: 'claude-sonnet-5-5',
|
|
43
|
+
[MODEL_ROLE.review]: 'claude-sonnet-5-5',
|
|
44
|
+
[MODEL_ROLE.explore]: 'claude-sonnet-5-5',
|
|
45
|
+
[MODEL_ROLE.orchestrate]: 'claude-opus-5-5',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What a global install, and a project that already has skills of kit names, puts before every kit name. */
|
|
49
|
+
export const DEFAULT_SKILL_PREFIX = 'jh-'
|
|
50
|
+
|
|
51
|
+
const SKILL_ENTRY = new RegExp(`^${SKILLS_DIRECTORY}/([^/]+)/SKILL\\.md$`)
|
|
52
|
+
const AGENT_ENTRY = new RegExp(`^${AGENTS_DIRECTORY}/([^/]+)\\.md$`)
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The kit's skills (`/name`) and agents (`name agent`) the project's `.claude/` already holds under the same
|
|
56
|
+
* name; `exists` answers for a path relative to `.claude/`.
|
|
57
|
+
*/
|
|
58
|
+
export const collidingNames = (kit: KitSource, exists: (path: string) => boolean): string[] =>
|
|
59
|
+
kit.files.flatMap(({ path }) => {
|
|
60
|
+
const skill = SKILL_ENTRY.exec(path)
|
|
61
|
+
const agent = AGENT_ENTRY.exec(path)
|
|
62
|
+
if (!exists(path)) return []
|
|
63
|
+
if (skill) return [`/${skill[1]}`]
|
|
64
|
+
return agent ? [`${agent[1]} agent`] : []
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The default config with every default spelled out, so the project sees what it can change; commands, lock file
|
|
69
|
+
* and base branch as detected in the project.
|
|
70
|
+
*/
|
|
71
|
+
const defaultConfigText = (facts: RepositoryFacts, skillPrefix: string, setup: SetupConfig | undefined): string => {
|
|
72
|
+
const config = parseJackherdConfig(withDetected({ models: DEFAULT_MODELS, skillPrefix, ...setup }, facts).raw)
|
|
73
|
+
return `${JSON.stringify({ $schema: CONFIG_SCHEMA_REFERENCE, ...config }, null, 2)}\n`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What each repository decides for itself; a global config that spelled them out would set them for all. */
|
|
77
|
+
const withoutRepositoryValues = ({ baseBranch: _base, commands: _commands, lanes, ...rest }: JackherdConfig) => {
|
|
78
|
+
const { lockFile: _lockFile, ...ownLanes } = lanes
|
|
79
|
+
return { ...rest, lanes: ownLanes }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The global default config: no `$schema`, since `~/.jackherd` has no `node_modules` beside it, and no commands,
|
|
84
|
+
* lock file or base branch, so every repository gets its own detected ones.
|
|
85
|
+
*/
|
|
86
|
+
const defaultGlobalConfigText = (setup: SetupConfig | undefined): string => {
|
|
87
|
+
const config = parseJackherdConfig({ models: DEFAULT_MODELS, skillPrefix: DEFAULT_SKILL_PREFIX, ...setup })
|
|
88
|
+
return `${JSON.stringify(withoutRepositoryValues(config), null, 2)}\n`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Init moves the old files before it writes a config, so the sync after it has nothing to name: the lines come first. */
|
|
92
|
+
const withMoves = (result: SyncResult, moved: string[]): SyncResult => ({
|
|
93
|
+
...result,
|
|
94
|
+
notes: [...moved, ...(result.notes ?? [])],
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const initResult = (configCreated: boolean, sync: SyncResult, config: JackherdConfig): InitResult => ({
|
|
98
|
+
configCreated,
|
|
99
|
+
sync,
|
|
100
|
+
skillPrefix: config.skillPrefix,
|
|
101
|
+
terminalAdapter: config.terminal.adapter,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
/** Writes `.jackherd/config.json` with the setup and defaults unless one exists, then runs sync. */
|
|
105
|
+
export const runInit = async ({
|
|
106
|
+
projectRoot,
|
|
107
|
+
home = homedir(),
|
|
108
|
+
kit,
|
|
109
|
+
facts = readRepositoryFacts(projectRoot),
|
|
110
|
+
skillPrefix = '',
|
|
111
|
+
setup,
|
|
112
|
+
}: InitRequest): Promise<InitResult> => {
|
|
113
|
+
await assertNotHomeDirectory(projectRoot, home)
|
|
114
|
+
await assertOwnDirectoriesInProject(projectRoot)
|
|
115
|
+
const moved = await moveProjectLegacyFiles(projectRoot)
|
|
116
|
+
const configCreated = await createFileExclusively(
|
|
117
|
+
join(projectRoot, TOOL_DIRECTORY),
|
|
118
|
+
CONFIG_FILE,
|
|
119
|
+
defaultConfigText(facts, skillPrefix, setup),
|
|
120
|
+
)
|
|
121
|
+
const { result, config } = await syncProjectWithConfig({ projectRoot, home, kit, accept: [], facts })
|
|
122
|
+
return initResult(configCreated, withMoves(result, moved), config)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Writes `~/.jackherd/config.json` with the setup, defaults and the `jh-` prefix unless one exists, then runs the global sync. */
|
|
126
|
+
export const runGlobalInit = async ({ home, kit, setup, configHome }: GlobalInitRequest): Promise<InitResult> => {
|
|
127
|
+
await assertOwnDirectoriesInProject(home)
|
|
128
|
+
const moved = await moveGlobalLegacyFiles(home, configHome)
|
|
129
|
+
const configCreated = await createFileExclusively(
|
|
130
|
+
join(home, TOOL_DIRECTORY),
|
|
131
|
+
CONFIG_FILE,
|
|
132
|
+
defaultGlobalConfigText(setup),
|
|
133
|
+
)
|
|
134
|
+
const { result, config } = await syncGlobalWithConfig({ home, kit, accept: [], configHome })
|
|
135
|
+
return initResult(configCreated, withMoves(result, moved), config)
|
|
136
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { errorMessage } from './error-message'
|
|
3
|
+
import { HOOK_NAME, type HookDefinition, hookDefinitionSchema } from './settings-hooks'
|
|
4
|
+
|
|
5
|
+
export type KitHooksResult = { ok: true; hooks: Record<string, HookDefinition> } | { ok: false; reason: string }
|
|
6
|
+
|
|
7
|
+
const kitHooksSchema = z.record(z.string().regex(HOOK_NAME), hookDefinitionSchema)
|
|
8
|
+
|
|
9
|
+
/** Parses the rendered `kit/hooks.json`: `{ "<name>": { event, matcher?, handler } }`. */
|
|
10
|
+
export const parseKitHooks = (rendered: string): KitHooksResult => {
|
|
11
|
+
try {
|
|
12
|
+
const parsed = kitHooksSchema.safeParse(JSON.parse(rendered))
|
|
13
|
+
if (!parsed.success) return { ok: false, reason: z.prettifyError(parsed.error) }
|
|
14
|
+
return { ok: true, hooks: parsed.data }
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return { ok: false, reason: errorMessage(err) }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { posix } from 'node:path'
|
|
2
|
+
import { CLAUDE_DIRECTORY, LOCAL_SETTINGS_FILE, SETTINGS_FILE } from '../claude/paths'
|
|
3
|
+
import { TOOL_DIRECTORY } from '../config/location'
|
|
4
|
+
|
|
5
|
+
export type KitPathResult = { ok: true; path: string } | { ok: false; reason: string }
|
|
6
|
+
|
|
7
|
+
const KIT_NEW_SUFFIX = '.kit-new'
|
|
8
|
+
|
|
9
|
+
/** The names config and lock had in `.claude/` before they moved to `.jackherd/`; no kit file may claim them. */
|
|
10
|
+
const LEGACY_CONFIG_FILE = 'agent-kit.json'
|
|
11
|
+
const LEGACY_MANIFEST_FILE = 'agent-kit.lock.json'
|
|
12
|
+
|
|
13
|
+
/** Files under `.claude/` sync owns itself or must never treat as a kit file; compared lower-case. */
|
|
14
|
+
const RESERVED_PATHS: readonly string[] = [LEGACY_CONFIG_FILE, LEGACY_MANIFEST_FILE, SETTINGS_FILE, LOCAL_SETTINGS_FILE]
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Plain ASCII names that do not start or end with a dot. Anything else (a dot-directory such as
|
|
18
|
+
* `.git`, Unicode a case-insensitive file system folds onto a reserved name, a trailing dot or
|
|
19
|
+
* space Windows drops, `:` streams, `~` short names) could alias another file.
|
|
20
|
+
*/
|
|
21
|
+
const SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/
|
|
22
|
+
|
|
23
|
+
const ABSOLUTE = /^(\/|[A-Za-z]:)/
|
|
24
|
+
|
|
25
|
+
const reject = (reason: string): KitPathResult => ({ ok: false, reason })
|
|
26
|
+
|
|
27
|
+
const isReserved = (path: string): boolean => {
|
|
28
|
+
const lowerPath = path.toLowerCase()
|
|
29
|
+
return RESERVED_PATHS.includes(lowerPath) || lowerPath.endsWith(KIT_NEW_SUFFIX)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The only lock paths a global install may own in the tool directory; its config, lock and state stay out of reach. */
|
|
33
|
+
const TOOL_ROOT_FILES = /^_jackherd\/(rules\/.+|statusline\.sh)$/
|
|
34
|
+
|
|
35
|
+
const isForbiddenToolPath = (path: string): boolean =>
|
|
36
|
+
path.toLowerCase().startsWith('_jackherd/') && !TOOL_ROOT_FILES.test(path)
|
|
37
|
+
|
|
38
|
+
const isPlainSegment = (segment: string): boolean => SEGMENT.test(segment) && !segment.endsWith('.')
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validates a path relative to `.claude/` that comes from the kit, the manifest or `--accept`.
|
|
42
|
+
* A leading `.claude/` is stripped so a path can be passed the way the report prints it. Anything
|
|
43
|
+
* that could resolve outside `.claude/` is refused; symlinks are checked where the disk is read.
|
|
44
|
+
* Reserved names are compared case-insensitively, since macOS and Windows file systems are.
|
|
45
|
+
*/
|
|
46
|
+
export const parseKitPath = (raw: string): KitPathResult => {
|
|
47
|
+
if (raw.includes('\0')) return reject('contains NUL')
|
|
48
|
+
if (raw.includes('\\')) return reject('contains a backslash')
|
|
49
|
+
if (ABSOLUTE.test(raw)) return reject('is absolute')
|
|
50
|
+
if (raw.split('/').includes('..')) return reject('leaves .claude/')
|
|
51
|
+
const segments = posix.normalize(raw).split('/')
|
|
52
|
+
const relative = segments[0] === CLAUDE_DIRECTORY ? segments.slice(1) : segments
|
|
53
|
+
const path = relative.filter((segment) => segment !== '.' && segment !== '').join('/')
|
|
54
|
+
if (path === '') return reject('is empty')
|
|
55
|
+
if (!path.split('/').every(isPlainSegment)) return reject(`has a segment that is not a plain file name: ${path}`)
|
|
56
|
+
if (isReserved(path)) return reject(`is reserved: ${path}`)
|
|
57
|
+
if (isForbiddenToolPath(path)) return reject(`is reserved: ${path}`)
|
|
58
|
+
return { ok: true, path }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A lock path below this segment lives in the tool directory (`~/.jackherd/`), not in `.claude/`: the rules and the
|
|
63
|
+
* status line of a global install, which Claude Code must not load on its own. Only a global sync writes them.
|
|
64
|
+
*/
|
|
65
|
+
export const TOOL_ROOT = '_jackherd'
|
|
66
|
+
|
|
67
|
+
/** The path inside the tool directory for a lock path below `TOOL_ROOT`, else null. */
|
|
68
|
+
export const toolRootPath = (path: string): string | null =>
|
|
69
|
+
path.startsWith(`${TOOL_ROOT}/`) ? path.slice(TOOL_ROOT.length + 1) : null
|
|
70
|
+
|
|
71
|
+
/** A lock path as the user finds it on disk: `~/.jackherd/…` for the tool root, else `.claude/…`. */
|
|
72
|
+
export const shownPath = (path: string): string => {
|
|
73
|
+
const inTool = toolRootPath(path)
|
|
74
|
+
return inTool === null ? `${CLAUDE_DIRECTORY}/${path}` : `~/${TOOL_DIRECTORY}/${inTool}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const kitNewPath = (path: string): string => `${path}${KIT_NEW_SUFFIX}`
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { lstat, readdir, readFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
import { PACKAGE_NAME } from '../version/release'
|
|
7
|
+
import type { KitFile, KitSource } from './kit'
|
|
8
|
+
import { parseKitPath } from './kit-path'
|
|
9
|
+
|
|
10
|
+
export type KitLocation = { kitDirectory: string; version: string; packageRoot: string }
|
|
11
|
+
|
|
12
|
+
/** What one kit module ships: its files and the text of its `hooks.json`, if any. */
|
|
13
|
+
type KitModule = { name: string; files: KitFile[]; hooksText: string | undefined }
|
|
14
|
+
|
|
15
|
+
const MODULE_HOOKS_FILE = 'hooks.json'
|
|
16
|
+
|
|
17
|
+
/** A test beside a kit script runs in this package and never ships into a project. */
|
|
18
|
+
const TEST_FILE = /\.test\.ts$/
|
|
19
|
+
|
|
20
|
+
/** A module directory name: plain ASCII, no leading dot, so `.cache` or `.git` never ship. */
|
|
21
|
+
const MODULE_NAME = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/
|
|
22
|
+
|
|
23
|
+
const ANY_EXECUTE_BIT = 0o111
|
|
24
|
+
|
|
25
|
+
const packageJsonSchema = z.looseObject({ name: z.string().optional(), version: z.string().min(1).optional() })
|
|
26
|
+
|
|
27
|
+
const hookObjectSchema = z.record(z.string(), z.unknown())
|
|
28
|
+
|
|
29
|
+
const parseJsonOr = (text: string, failure: Error): unknown => {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(text)
|
|
32
|
+
} catch {
|
|
33
|
+
throw failure
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const assertUniquePaths = (modules: KitModule[]): void => {
|
|
38
|
+
const owner = new Map<string, string>()
|
|
39
|
+
for (const { name, files } of modules) {
|
|
40
|
+
for (const { path } of files) {
|
|
41
|
+
const other = owner.get(path)
|
|
42
|
+
if (other) throw new Error(`Kit file ${path} ships in modules ${other} and ${name}`)
|
|
43
|
+
owner.set(path, name)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const parseModuleHooks = ({ name, hooksText }: KitModule): Record<string, unknown> => {
|
|
49
|
+
if (hooksText === undefined) return {}
|
|
50
|
+
const notAnObject = new Error(`Kit ${name}/${MODULE_HOOKS_FILE} is not a JSON object of hooks`)
|
|
51
|
+
const parsed = hookObjectSchema.safeParse(parseJsonOr(hooksText, notAnObject))
|
|
52
|
+
if (!parsed.success) throw notAnObject
|
|
53
|
+
return parsed.data
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Merges the modules' hooks into one unrendered template. Placeholders sit inside JSON strings,
|
|
58
|
+
* so the text parses before rendering; a hook name shipped twice is an error.
|
|
59
|
+
*/
|
|
60
|
+
const mergeModuleHooks = (modules: KitModule[]): string | undefined => {
|
|
61
|
+
const merged: Record<string, unknown> = {}
|
|
62
|
+
for (const module of modules) {
|
|
63
|
+
for (const [hookName, hook] of Object.entries(parseModuleHooks(module))) {
|
|
64
|
+
if (Object.hasOwn(merged, hookName)) throw new Error(`Kit hook ${hookName} ships in more than one module`)
|
|
65
|
+
merged[hookName] = hook
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return Object.keys(merged).length > 0 ? JSON.stringify(merged, null, 2) : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const listFiles = async (directory: string, prefix = ''): Promise<string[]> => {
|
|
72
|
+
const entries = await readdir(join(directory, prefix), { withFileTypes: true })
|
|
73
|
+
const nested = await Promise.all(
|
|
74
|
+
entries.map(async (entry) => {
|
|
75
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name
|
|
76
|
+
if (entry.isDirectory()) return listFiles(directory, relative)
|
|
77
|
+
if (entry.isFile()) return [relative]
|
|
78
|
+
throw new Error(`Kit entry ${relative} is neither a file nor a directory`)
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
return nested.flat()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const readKitFile = async (moduleDirectory: string, relative: string): Promise<KitFile> => {
|
|
85
|
+
const parsed = parseKitPath(relative)
|
|
86
|
+
if (!parsed.ok || parsed.path !== relative) {
|
|
87
|
+
throw new Error(`Kit file ${relative} ${parsed.ok ? 'is not normalised' : parsed.reason}`)
|
|
88
|
+
}
|
|
89
|
+
const absolute = join(moduleDirectory, relative)
|
|
90
|
+
const [template, entry] = await Promise.all([readFile(absolute, 'utf8'), lstat(absolute)])
|
|
91
|
+
return { path: relative, template, executable: (entry.mode & ANY_EXECUTE_BIT) !== 0 }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const readModule = async (kitDirectory: string, name: string): Promise<KitModule> => {
|
|
95
|
+
const moduleDirectory = join(kitDirectory, name)
|
|
96
|
+
const relatives = (await listFiles(moduleDirectory)).sort()
|
|
97
|
+
const filePaths = relatives.filter((relative) => relative !== MODULE_HOOKS_FILE && !TEST_FILE.test(relative))
|
|
98
|
+
const files = await Promise.all(filePaths.map((relative) => readKitFile(moduleDirectory, relative)))
|
|
99
|
+
const hooksText = relatives.includes(MODULE_HOOKS_FILE)
|
|
100
|
+
? await readFile(join(moduleDirectory, MODULE_HOOKS_FILE), 'utf8')
|
|
101
|
+
: undefined
|
|
102
|
+
return { name, files, hooksText }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const listModuleNames = async (kitDirectory: string): Promise<string[]> => {
|
|
106
|
+
const entries = await readdir(kitDirectory, { withFileTypes: true })
|
|
107
|
+
const symlinked = entries.find((entry) => entry.isSymbolicLink())
|
|
108
|
+
if (symlinked) throw new Error(`Kit module ${symlinked.name} is a symlink`)
|
|
109
|
+
const names = entries
|
|
110
|
+
.filter((entry) => entry.isDirectory())
|
|
111
|
+
.map(({ name }) => name)
|
|
112
|
+
.sort()
|
|
113
|
+
const invalid = names.find((name) => !MODULE_NAME.test(name))
|
|
114
|
+
if (invalid) throw new Error(`Kit module ${invalid} is not a plain directory name`)
|
|
115
|
+
if (names.length === 0) throw new Error(`Kit directory ${kitDirectory} holds no modules`)
|
|
116
|
+
return names
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Reads the kit shipped with the package. Every directory under `kit/` is a module that mirrors
|
|
121
|
+
* `.claude/` (`kit/workflow/skills/plan/SKILL.md` → `.claude/skills/plan/SKILL.md`); a module's
|
|
122
|
+
* `hooks.json` holds its hooks by name, `{ "<name>": { event, matcher?, handler } }`, and is not
|
|
123
|
+
* copied, nor is a `*.test.ts`. Files at the kit root, such as a README, belong to no module.
|
|
124
|
+
*
|
|
125
|
+
* A missing kit directory, or one without modules, is an error, never an empty kit: an empty kit
|
|
126
|
+
* would make sync remove every untouched kit file from the project.
|
|
127
|
+
*/
|
|
128
|
+
export const readKitSource = async ({
|
|
129
|
+
kitDirectory,
|
|
130
|
+
version,
|
|
131
|
+
}: Pick<KitLocation, 'kitDirectory' | 'version'>): Promise<KitSource> => {
|
|
132
|
+
if (!existsSync(kitDirectory)) throw new Error(`Kit directory not found: ${kitDirectory}`)
|
|
133
|
+
const names = await listModuleNames(kitDirectory)
|
|
134
|
+
const modules = await Promise.all(names.map((name) => readModule(kitDirectory, name)))
|
|
135
|
+
assertUniquePaths(modules)
|
|
136
|
+
const files = modules.flatMap((module) => module.files).sort((left, right) => (left.path < right.path ? -1 : 1))
|
|
137
|
+
return { version, files, hooksTemplate: mergeModuleHooks(modules) }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const readPackageJson = async (path: string) => packageJsonSchema.parse(JSON.parse(await readFile(path, 'utf8')))
|
|
141
|
+
|
|
142
|
+
const KIT_DIRECTORY = 'kit'
|
|
143
|
+
|
|
144
|
+
const kitDirectoryIn = (packageRoot: string): string => {
|
|
145
|
+
const found = join(packageRoot, KIT_DIRECTORY)
|
|
146
|
+
if (!existsSync(found)) throw new Error(`No kit directory in ${packageRoot} (looked for ${KIT_DIRECTORY})`)
|
|
147
|
+
return found
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Finds the package root by walking up from this module in `src/`, then resolves the `kit/`
|
|
152
|
+
* directory below it. The repository and the published package both ship `kit/`, so there is one
|
|
153
|
+
* place to look.
|
|
154
|
+
*/
|
|
155
|
+
export const locateKit = async (from: string = fileURLToPath(import.meta.url)): Promise<KitLocation> => {
|
|
156
|
+
let directory = dirname(from)
|
|
157
|
+
while (directory !== dirname(directory)) {
|
|
158
|
+
const packageJsonPath = join(directory, 'package.json')
|
|
159
|
+
if (existsSync(packageJsonPath)) {
|
|
160
|
+
const packageJson = await readPackageJson(packageJsonPath)
|
|
161
|
+
if (packageJson.name === PACKAGE_NAME) {
|
|
162
|
+
if (!packageJson.version) throw new Error(`${packageJsonPath} has no version`)
|
|
163
|
+
return { kitDirectory: kitDirectoryIn(directory), version: packageJson.version, packageRoot: directory }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
directory = dirname(directory)
|
|
167
|
+
}
|
|
168
|
+
throw new Error(`Could not find the ${PACKAGE_NAME} package root above ${from}`)
|
|
169
|
+
}
|
package/src/sync/kit.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** A file the kit ships, by its path relative to `.claude/`, still unrendered. */
|
|
2
|
+
export type KitFile = { path: string; template: string; executable: boolean }
|
|
3
|
+
|
|
4
|
+
export type KitSource = {
|
|
5
|
+
version: string
|
|
6
|
+
files: KitFile[]
|
|
7
|
+
/** The unrendered text of `kit/hooks.json`, if the kit ships hooks. */
|
|
8
|
+
hooksTemplate: string | undefined
|
|
9
|
+
}
|