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,116 @@
|
|
|
1
|
+
import { lstat, mkdir, readdir, readFile, rename, rm, rmdir, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
LEGACY_UPDATE_STATE_PATH,
|
|
5
|
+
legacyPublicContentPath,
|
|
6
|
+
legacyRenames,
|
|
7
|
+
PROJECT_LEGACY_FILES,
|
|
8
|
+
PUBLIC_CONTENT_PATH,
|
|
9
|
+
RENAMED_NOTICE,
|
|
10
|
+
type Rename,
|
|
11
|
+
withCurrentSchemaReference,
|
|
12
|
+
} from '../config/legacy-names'
|
|
13
|
+
import { PROJECT_CONFIG_PATH } from '../config/location'
|
|
14
|
+
|
|
15
|
+
/** Where earlier global installs put rules and status line below `~/.claude/`: before the rename, and 0.28 on main. */
|
|
16
|
+
const LEGACY_GLOBAL_DIRECTORIES = ['agent-kit', 'jackherd']
|
|
17
|
+
|
|
18
|
+
const isThere = async (path: string): Promise<boolean> => {
|
|
19
|
+
try {
|
|
20
|
+
await lstat(path)
|
|
21
|
+
return true
|
|
22
|
+
} catch {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const presentPaths = async (root: string, pairs: readonly Rename[]): Promise<string[]> => {
|
|
28
|
+
const paths = pairs.flatMap(({ from, to }) => [from, to])
|
|
29
|
+
const found = await Promise.all(paths.map(async (path) => ((await isThere(join(root, path))) ? [path] : [])))
|
|
30
|
+
return found.flat()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A moved regular config gets the new `$schema`; a link is moved as it is and never read through. */
|
|
34
|
+
const updateSchemaReference = async (path: string): Promise<void> => {
|
|
35
|
+
if (!(await lstat(path)).isFile()) return
|
|
36
|
+
const text = await readFile(path, 'utf8')
|
|
37
|
+
const next = withCurrentSchemaReference(text)
|
|
38
|
+
if (next !== text) await writeFile(path, next)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const moveOne = async (root: string, { from, to }: Rename): Promise<string> => {
|
|
42
|
+
await mkdir(dirname(join(root, to)), { recursive: true })
|
|
43
|
+
await rename(join(root, from), join(root, to))
|
|
44
|
+
if (to === PROJECT_CONFIG_PATH) await updateSchemaReference(join(root, to))
|
|
45
|
+
return `Moved ${from} to ${to}.`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const shadowedNote = ({ from, to }: Rename): string =>
|
|
49
|
+
`${from} stays: ${to} is there and wins. Delete the old file once nothing reads it.`
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Moves the old files of `pairs` below `root` to their new names before anything reads them, and returns a line
|
|
53
|
+
* per move or per old file a new one shadows. A file is renamed, never copied, so a link stays a link.
|
|
54
|
+
*/
|
|
55
|
+
export const moveLegacyFiles = async (root: string, pairs: readonly Rename[]): Promise<string[]> => {
|
|
56
|
+
const plan = legacyRenames(pairs, await presentPaths(root, pairs))
|
|
57
|
+
const moved: string[] = []
|
|
58
|
+
for (const rename of plan.renames) moved.push(await moveOne(root, rename))
|
|
59
|
+
const notice = moved.length > 0 ? [RENAMED_NOTICE] : []
|
|
60
|
+
return [...notice, ...moved, ...plan.shadowed.map(shadowedNote)]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The project's old config and lock. */
|
|
64
|
+
export const moveProjectLegacyFiles = (projectRoot: string): Promise<string[]> =>
|
|
65
|
+
moveLegacyFiles(projectRoot, PROJECT_LEGACY_FILES)
|
|
66
|
+
|
|
67
|
+
/** The old public-content facts, below `XDG_CONFIG_HOME` as the reader finds them, move into `~/.jackherd/`. */
|
|
68
|
+
const movePublicContent = async (home: string, configHome: string | undefined): Promise<string[]> => {
|
|
69
|
+
const from = legacyPublicContentPath(home, configHome)
|
|
70
|
+
const to = join(home, PUBLIC_CONTENT_PATH)
|
|
71
|
+
if (!(await isThere(from))) return []
|
|
72
|
+
if (await isThere(to)) return [shadowedNote({ from, to: PUBLIC_CONTENT_PATH })]
|
|
73
|
+
await mkdir(dirname(to), { recursive: true })
|
|
74
|
+
try {
|
|
75
|
+
await rename(from, to)
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if ((err as NodeJS.ErrnoException).code !== 'EXDEV') throw err
|
|
78
|
+
return [`${from} stays where it is (another file system); it is still read there.`]
|
|
79
|
+
}
|
|
80
|
+
return [`Moved ${from} to ${PUBLIC_CONTENT_PATH}.`]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The old update check is deleted, not read; only a file or a link at its path, never a directory. */
|
|
84
|
+
const removeLegacyUpdateState = async (home: string): Promise<void> => {
|
|
85
|
+
const path = join(home, LEGACY_UPDATE_STATE_PATH)
|
|
86
|
+
const entry = await lstat(path).catch(() => null)
|
|
87
|
+
if (entry && (entry.isFile() || entry.isSymbolicLink())) await rm(path)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The home directory's old config, lock and public-content facts move; the old update check goes. `configHome` is
|
|
92
|
+
* `XDG_CONFIG_HOME` as the command read it, undefined for `~/.config`; never read from the environment here, so a
|
|
93
|
+
* test home cannot reach the real one.
|
|
94
|
+
*/
|
|
95
|
+
export const moveGlobalLegacyFiles = async (home: string, configHome: string | undefined): Promise<string[]> => {
|
|
96
|
+
const notes = await moveLegacyFiles(home, PROJECT_LEGACY_FILES)
|
|
97
|
+
const publicContent = await movePublicContent(home, configHome)
|
|
98
|
+
await removeLegacyUpdateState(home)
|
|
99
|
+
return [...notes, ...publicContent]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Removes `directory` and the directories below it that hold nothing; a file or a link keeps its directories. */
|
|
103
|
+
const removeEmptyDirectories = async (directory: string): Promise<void> => {
|
|
104
|
+
const entry = await lstat(directory).catch(() => null)
|
|
105
|
+
if (!entry?.isDirectory()) return
|
|
106
|
+
for (const name of await readdir(directory)) await removeEmptyDirectories(join(directory, name))
|
|
107
|
+
if ((await readdir(directory)).length === 0) await rmdir(directory)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* After a global sync removed the kit's files from the old places in `~/.claude/`, the directories they leave empty
|
|
112
|
+
* go too; a file of the user's own keeps its directory and everything above it.
|
|
113
|
+
*/
|
|
114
|
+
export const removeEmptyLegacyDirectories = async (claudeDirectory: string): Promise<void> => {
|
|
115
|
+
for (const name of LEGACY_GLOBAL_DIRECTORIES) await removeEmptyDirectories(join(claudeDirectory, name))
|
|
116
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { parseKitPath } from './kit-path'
|
|
3
|
+
import { HOOK_NAME, hookDefinitionSchema } from './settings-hooks'
|
|
4
|
+
|
|
5
|
+
const sha256 = z.string().regex(/^[0-9a-f]{64}$/)
|
|
6
|
+
|
|
7
|
+
/** Only the normalised form is accepted: a manifest path must be exactly what sync itself wrote. */
|
|
8
|
+
const kitPath = z.string().refine((raw) => {
|
|
9
|
+
const parsed = parseKitPath(raw)
|
|
10
|
+
return parsed.ok && parsed.path === raw
|
|
11
|
+
}, 'not a path inside .claude/')
|
|
12
|
+
|
|
13
|
+
// TODO(security audit M2): the manifest alone authorises a
|
|
14
|
+
// removal. A hand-edited entry whose checksum matches a checked-in file or hook outside the kit
|
|
15
|
+
// makes sync remove it. Open decision: ship a history of every kit checksum and remove only what
|
|
16
|
+
// the kit once wrote, or require --accept for removals.
|
|
17
|
+
const manifestSchema = z.strictObject({
|
|
18
|
+
kitVersion: z.string().min(1),
|
|
19
|
+
files: z.record(kitPath, z.strictObject({ base: sha256.optional(), pending: sha256.optional() })),
|
|
20
|
+
hooks: z.record(
|
|
21
|
+
z.string().regex(HOOK_NAME),
|
|
22
|
+
z.strictObject({ base: hookDefinitionSchema, pending: sha256.optional() }),
|
|
23
|
+
),
|
|
24
|
+
/** Checksum of the `settings.json.kit-new` sync wrote last. */
|
|
25
|
+
settingsKitNew: sha256.optional(),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
export type Manifest = z.infer<typeof manifestSchema>
|
|
29
|
+
|
|
30
|
+
export type ManifestResult = { ok: true; manifest: Manifest } | { ok: false; reason: string }
|
|
31
|
+
|
|
32
|
+
export const EMPTY_MANIFEST: Manifest = { kitVersion: '0.0.0', files: {}, hooks: {} }
|
|
33
|
+
|
|
34
|
+
export const parseManifest = (raw: unknown): ManifestResult => {
|
|
35
|
+
const parsed = manifestSchema.safeParse(raw)
|
|
36
|
+
if (!parsed.success) return { ok: false, reason: z.prettifyError(parsed.error) }
|
|
37
|
+
return { ok: true, manifest: parsed.data }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sortedRecord = <T>(record: Record<string, T>): Record<string, T> =>
|
|
41
|
+
Object.fromEntries(Object.entries(record).sort(([left], [right]) => (left < right ? -1 : 1)))
|
|
42
|
+
|
|
43
|
+
export const serializeManifest = (manifest: Manifest): string =>
|
|
44
|
+
`${JSON.stringify({ ...manifest, files: sortedRecord(manifest.files), hooks: sortedRecord(manifest.hooks) }, null, 2)}\n`
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { TERMINAL_ADAPTER, type TerminalAdapter } from '../config'
|
|
2
|
+
import { PACKAGE_NAME } from '../version/release'
|
|
3
|
+
import type { ToolPresence } from './tools'
|
|
4
|
+
|
|
5
|
+
export type NextStepsRequest = {
|
|
6
|
+
/** The config's prefix: empty in a project without one, `kit-` in a global install. */
|
|
7
|
+
skillPrefix: string
|
|
8
|
+
adapter: TerminalAdapter
|
|
9
|
+
tools: ToolPresence
|
|
10
|
+
/** The kit's version, which pins the docs link. */
|
|
11
|
+
version: string
|
|
12
|
+
/** How the user runs the CLI here: `jh` after a global install, `bunx jackherd` in a project. */
|
|
13
|
+
cli: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const herdrLine = (orchestrate: string, adapter: TerminalAdapter, tools: ToolPresence): string => {
|
|
17
|
+
const usesHerdr = adapter === TERMINAL_ADAPTER.herdr
|
|
18
|
+
if (tools.herdr && usesHerdr) return `herdr: ready; ${orchestrate} opens one workspace per lane.`
|
|
19
|
+
if (tools.herdr) return 'herdr: found, but lanes are off; set terminal.adapter to "herdr" in the config.'
|
|
20
|
+
if (!tools.herdrInstallable)
|
|
21
|
+
return 'herdr: not available on this system; every skill but parallel lanes works without it.'
|
|
22
|
+
if (usesHerdr) return 'herdr: not found, but the config uses it; install herdr.'
|
|
23
|
+
return 'herdr: not found; for lanes, install herdr and set terminal.adapter to "herdr" in the config.'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ghLine = (orchestrate: string, { gh }: ToolPresence): string =>
|
|
27
|
+
gh ? 'gh: found.' : `gh: not found; install it to let ${orchestrate} follow pull requests.`
|
|
28
|
+
|
|
29
|
+
/** What to do after `init`, eight lines: the skills by their installed names, what is ready, where to read on. */
|
|
30
|
+
export const nextSteps = ({ skillPrefix, adapter, tools, version, cli }: NextStepsRequest): string[] => {
|
|
31
|
+
const orchestrate = `/${skillPrefix}orchestrate`
|
|
32
|
+
return [
|
|
33
|
+
`/${skillPrefix}start: check your setup and begin the first plan.`,
|
|
34
|
+
`/${skillPrefix}plan: write a plan with phases.`,
|
|
35
|
+
`/${skillPrefix}implement: build one phase of a plan.`,
|
|
36
|
+
`${orchestrate}: run a board's phases in parallel lanes.`,
|
|
37
|
+
herdrLine(orchestrate, adapter, tools),
|
|
38
|
+
ghLine(orchestrate, tools),
|
|
39
|
+
`${cli} plans: see every plan and lane in one view.`,
|
|
40
|
+
`Docs: https://www.npmjs.com/package/${PACKAGE_NAME}/v/${version}#documentation`,
|
|
41
|
+
]
|
|
42
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `base` is the checksum of the kit version the local file is built on: what sync last wrote, or
|
|
3
|
+
* the version the user resolved a conflict against. `pending` is the checksum of the kit version
|
|
4
|
+
* sync wrote to `<file>.kit-new` and that the user has not dealt with yet. A foreign file at a kit
|
|
5
|
+
* path has a `pending` but no `base`.
|
|
6
|
+
*/
|
|
7
|
+
export type FileEntry = { base?: string; pending?: string }
|
|
8
|
+
|
|
9
|
+
export type LocalState = { kind: 'missing' } | { kind: 'file'; checksum: string } | { kind: 'notFile' }
|
|
10
|
+
|
|
11
|
+
export const FILE_MODE = { regular: 'regular', executable: 'executable' } as const
|
|
12
|
+
|
|
13
|
+
export type FileMode = (typeof FILE_MODE)[keyof typeof FILE_MODE]
|
|
14
|
+
|
|
15
|
+
export type RenderedFile = { content: string; checksum: string; mode: FileMode }
|
|
16
|
+
|
|
17
|
+
export type FileInput = {
|
|
18
|
+
path: string
|
|
19
|
+
/** `null` when the kit no longer ships the file. */
|
|
20
|
+
rendered: RenderedFile | null
|
|
21
|
+
entry: FileEntry | undefined
|
|
22
|
+
local: LocalState
|
|
23
|
+
kitNew: LocalState
|
|
24
|
+
accept: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type FileTarget = 'file' | 'kitNew'
|
|
28
|
+
|
|
29
|
+
export type FileEffect =
|
|
30
|
+
| { kind: 'write'; target: FileTarget; content: string; mode: FileMode }
|
|
31
|
+
| { kind: 'remove'; target: FileTarget }
|
|
32
|
+
|
|
33
|
+
export const FILE_OUTCOME = {
|
|
34
|
+
created: 'created',
|
|
35
|
+
updated: 'updated',
|
|
36
|
+
unchanged: 'unchanged',
|
|
37
|
+
removed: 'removed',
|
|
38
|
+
kept: 'kept',
|
|
39
|
+
untracked: 'untracked',
|
|
40
|
+
deselected: 'deselected',
|
|
41
|
+
dropped: 'dropped',
|
|
42
|
+
resolved: 'resolved',
|
|
43
|
+
conflict: 'conflict',
|
|
44
|
+
blocked: 'blocked',
|
|
45
|
+
accepted: 'accepted',
|
|
46
|
+
notAcceptable: 'notAcceptable',
|
|
47
|
+
} as const
|
|
48
|
+
|
|
49
|
+
export type FileOutcome = (typeof FILE_OUTCOME)[keyof typeof FILE_OUTCOME]
|
|
50
|
+
|
|
51
|
+
export type FilePlan = { path: string; outcome: FileOutcome; effects: FileEffect[]; entry: FileEntry | undefined }
|
|
52
|
+
|
|
53
|
+
const checksumOf = (state: LocalState): string | undefined => (state.kind === 'file' ? state.checksum : undefined)
|
|
54
|
+
|
|
55
|
+
/** Only a `.kit-new` that still holds exactly what sync wrote there may be replaced or removed. */
|
|
56
|
+
const isOwnKitNew = (input: FileInput): boolean =>
|
|
57
|
+
input.entry?.pending !== undefined && checksumOf(input.kitNew) === input.entry.pending
|
|
58
|
+
|
|
59
|
+
const cleanUpKitNew = (input: FileInput): FileEffect[] =>
|
|
60
|
+
isOwnKitNew(input) ? [{ kind: 'remove', target: 'kitNew' }] : []
|
|
61
|
+
|
|
62
|
+
const plan = (
|
|
63
|
+
input: FileInput,
|
|
64
|
+
outcome: FileOutcome,
|
|
65
|
+
effects: FileEffect[],
|
|
66
|
+
entry: FileEntry | undefined,
|
|
67
|
+
): FilePlan => ({
|
|
68
|
+
path: input.path,
|
|
69
|
+
outcome,
|
|
70
|
+
effects,
|
|
71
|
+
entry,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const leaveAlone = (input: FileInput, outcome: FileOutcome): FilePlan => plan(input, outcome, [], input.entry)
|
|
75
|
+
|
|
76
|
+
const planKitDropped = (input: FileInput): FilePlan => {
|
|
77
|
+
if (input.local.kind === 'missing' || input.entry?.base === undefined) {
|
|
78
|
+
return plan(input, FILE_OUTCOME.dropped, cleanUpKitNew(input), undefined)
|
|
79
|
+
}
|
|
80
|
+
if (checksumOf(input.local) === input.entry.base) {
|
|
81
|
+
return plan(input, FILE_OUTCOME.removed, [{ kind: 'remove', target: 'file' }, ...cleanUpKitNew(input)], undefined)
|
|
82
|
+
}
|
|
83
|
+
return plan(input, FILE_OUTCOME.untracked, [], undefined)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const planConflict = (input: FileInput, rendered: RenderedFile): FilePlan => {
|
|
87
|
+
const entry = { ...input.entry, pending: rendered.checksum }
|
|
88
|
+
if (checksumOf(input.kitNew) === rendered.checksum) {
|
|
89
|
+
return plan(input, FILE_OUTCOME.conflict, [], entry)
|
|
90
|
+
}
|
|
91
|
+
if (input.kitNew.kind === 'missing' || isOwnKitNew(input)) {
|
|
92
|
+
return plan(
|
|
93
|
+
input,
|
|
94
|
+
FILE_OUTCOME.conflict,
|
|
95
|
+
[{ kind: 'write', target: 'kitNew', content: rendered.content, mode: FILE_MODE.regular }],
|
|
96
|
+
entry,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
return leaveAlone(input, FILE_OUTCOME.blocked)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const planLocallyModified = (input: FileInput, rendered: RenderedFile): FilePlan => {
|
|
103
|
+
const { entry } = input
|
|
104
|
+
if (entry?.base === rendered.checksum) return leaveAlone(input, FILE_OUTCOME.kept)
|
|
105
|
+
if (entry?.pending === rendered.checksum && input.kitNew.kind === 'missing') {
|
|
106
|
+
return plan(input, FILE_OUTCOME.resolved, [], { base: rendered.checksum })
|
|
107
|
+
}
|
|
108
|
+
return planConflict(input, rendered)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const planKitShipped = (input: FileInput, rendered: RenderedFile): FilePlan => {
|
|
112
|
+
const write: FileEffect = { kind: 'write', target: 'file', content: rendered.content, mode: rendered.mode }
|
|
113
|
+
const local = checksumOf(input.local)
|
|
114
|
+
const base = input.entry?.base
|
|
115
|
+
if (input.local.kind === 'missing') {
|
|
116
|
+
if (input.entry) return leaveAlone(input, FILE_OUTCOME.deselected)
|
|
117
|
+
return plan(input, FILE_OUTCOME.created, [write], { base: rendered.checksum })
|
|
118
|
+
}
|
|
119
|
+
if (local === rendered.checksum) {
|
|
120
|
+
return plan(input, FILE_OUTCOME.unchanged, cleanUpKitNew(input), { base: rendered.checksum })
|
|
121
|
+
}
|
|
122
|
+
if (base !== undefined && local === base) {
|
|
123
|
+
return plan(input, FILE_OUTCOME.updated, [write, ...cleanUpKitNew(input)], { base: rendered.checksum })
|
|
124
|
+
}
|
|
125
|
+
return planLocallyModified(input, rendered)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const ACCEPTABLE: readonly FileOutcome[] = [FILE_OUTCOME.conflict, FILE_OUTCOME.blocked, FILE_OUTCOME.deselected]
|
|
129
|
+
|
|
130
|
+
const planAccept = (input: FileInput, rendered: RenderedFile, withoutAccept: FilePlan): FilePlan => {
|
|
131
|
+
if (!ACCEPTABLE.includes(withoutAccept.outcome)) return leaveAlone(input, FILE_OUTCOME.notAcceptable)
|
|
132
|
+
return plan(
|
|
133
|
+
input,
|
|
134
|
+
FILE_OUTCOME.accepted,
|
|
135
|
+
[{ kind: 'write', target: 'file', content: rendered.content, mode: rendered.mode }, ...cleanUpKitNew(input)],
|
|
136
|
+
{ base: rendered.checksum },
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Decides what sync does with one kit path. Pure: the caller reads the disk, applies the effects
|
|
142
|
+
* and stores the returned manifest entry (`undefined` = forget the path). A local change is never
|
|
143
|
+
* overwritten or removed unless `accept` is set for this path.
|
|
144
|
+
*/
|
|
145
|
+
export const planFile = (input: FileInput): FilePlan => {
|
|
146
|
+
if (input.local.kind === 'notFile') return leaveAlone(input, FILE_OUTCOME.blocked)
|
|
147
|
+
const { rendered } = input
|
|
148
|
+
if (rendered === null) {
|
|
149
|
+
const dropped = planKitDropped(input)
|
|
150
|
+
return input.accept ? leaveAlone(input, FILE_OUTCOME.notAcceptable) : dropped
|
|
151
|
+
}
|
|
152
|
+
const withoutAccept = planKitShipped(input, rendered)
|
|
153
|
+
return input.accept ? planAccept(input, rendered, withoutAccept) : withoutAccept
|
|
154
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { canonicalJson, checksum } from './checksum'
|
|
2
|
+
import type { LocalState } from './plan-file'
|
|
3
|
+
import { addHook, containsHook, type HookDefinition, hookKey, removeHook, type Settings } from './settings-hooks'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `base` is the kit hook sync last put into the settings (stored whole, so it can be found and
|
|
7
|
+
* removed again); `pending` is the key of a newer kit version proposed in `settings.json.kit-new`.
|
|
8
|
+
*/
|
|
9
|
+
export type HookEntry = { base: HookDefinition; pending?: string }
|
|
10
|
+
|
|
11
|
+
export type HooksInput = {
|
|
12
|
+
/** Kit hooks by their name in the kit. */
|
|
13
|
+
kit: Record<string, HookDefinition>
|
|
14
|
+
entries: Record<string, HookEntry>
|
|
15
|
+
settings: Settings
|
|
16
|
+
/** State of `.claude/settings.json.kit-new`. */
|
|
17
|
+
kitNew: LocalState
|
|
18
|
+
/** Checksum of the `settings.json.kit-new` sync wrote last, if it wrote one. */
|
|
19
|
+
kitNewRecorded: string | undefined
|
|
20
|
+
accept: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const HOOK_OUTCOME = {
|
|
24
|
+
created: 'created',
|
|
25
|
+
updated: 'updated',
|
|
26
|
+
unchanged: 'unchanged',
|
|
27
|
+
removed: 'removed',
|
|
28
|
+
dropped: 'dropped',
|
|
29
|
+
deselected: 'deselected',
|
|
30
|
+
resolved: 'resolved',
|
|
31
|
+
conflict: 'conflict',
|
|
32
|
+
blocked: 'blocked',
|
|
33
|
+
accepted: 'accepted',
|
|
34
|
+
} as const
|
|
35
|
+
|
|
36
|
+
export type HookOutcome = (typeof HOOK_OUTCOME)[keyof typeof HOOK_OUTCOME]
|
|
37
|
+
|
|
38
|
+
export type KitNewEffect = { kind: 'none' } | { kind: 'write'; content: string } | { kind: 'remove' }
|
|
39
|
+
|
|
40
|
+
export type HooksPlan = {
|
|
41
|
+
settings: Settings
|
|
42
|
+
/** False when the settings are semantically the same as before; the file is then not rewritten. */
|
|
43
|
+
changed: boolean
|
|
44
|
+
entries: Record<string, HookEntry>
|
|
45
|
+
outcomes: { name: string; outcome: HookOutcome }[]
|
|
46
|
+
kitNew: KitNewEffect
|
|
47
|
+
kitNewRecorded: string | undefined
|
|
48
|
+
/** `--accept` was given for the settings but no hook was in conflict or deselected. */
|
|
49
|
+
notAcceptable: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type HookDecision = {
|
|
53
|
+
name: string
|
|
54
|
+
outcome: HookOutcome
|
|
55
|
+
entry: HookEntry | undefined
|
|
56
|
+
remove?: HookDefinition
|
|
57
|
+
add?: HookDefinition
|
|
58
|
+
proposal?: HookDefinition
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const decideDropped = (name: string, entry: HookEntry, settings: Settings): HookDecision =>
|
|
62
|
+
containsHook(settings, entry.base)
|
|
63
|
+
? { name, outcome: HOOK_OUTCOME.removed, entry: undefined, remove: entry.base }
|
|
64
|
+
: { name, outcome: HOOK_OUTCOME.dropped, entry: undefined }
|
|
65
|
+
|
|
66
|
+
const decideMissingBase = (name: string, kit: HookDefinition, entry: HookEntry, input: HooksInput): HookDecision => {
|
|
67
|
+
const kitKey = hookKey(kit)
|
|
68
|
+
if (kitKey === hookKey(entry.base)) return { name, outcome: HOOK_OUTCOME.deselected, entry }
|
|
69
|
+
if (entry.pending === kitKey && input.kitNew.kind === 'missing') {
|
|
70
|
+
return { name, outcome: HOOK_OUTCOME.resolved, entry: { base: kit } }
|
|
71
|
+
}
|
|
72
|
+
return { name, outcome: HOOK_OUTCOME.conflict, entry, proposal: kit }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const decideShipped = (name: string, kit: HookDefinition, input: HooksInput): HookDecision => {
|
|
76
|
+
const entry = input.entries[name]
|
|
77
|
+
if (containsHook(input.settings, kit)) return { name, outcome: HOOK_OUTCOME.unchanged, entry: { base: kit } }
|
|
78
|
+
if (!entry) return { name, outcome: HOOK_OUTCOME.created, entry: { base: kit }, add: kit }
|
|
79
|
+
if (containsHook(input.settings, entry.base)) {
|
|
80
|
+
return { name, outcome: HOOK_OUTCOME.updated, entry: { base: kit }, remove: entry.base, add: kit }
|
|
81
|
+
}
|
|
82
|
+
return decideMissingBase(name, kit, entry, input)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const decide = (name: string, input: HooksInput): HookDecision => {
|
|
86
|
+
const kit = input.kit[name]
|
|
87
|
+
if (kit) return decideShipped(name, kit, input)
|
|
88
|
+
return decideDropped(name, input.entries[name], input.settings)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const ACCEPTABLE: readonly HookOutcome[] = [HOOK_OUTCOME.conflict, HOOK_OUTCOME.deselected]
|
|
92
|
+
|
|
93
|
+
const applyAccept = (decision: HookDecision, input: HooksInput): HookDecision => {
|
|
94
|
+
const kit = input.kit[decision.name]
|
|
95
|
+
if (!input.accept || !kit || !ACCEPTABLE.includes(decision.outcome)) return decision
|
|
96
|
+
return { name: decision.name, outcome: HOOK_OUTCOME.accepted, entry: { base: kit }, add: kit }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const proposalContent = (proposals: HookDefinition[]): string =>
|
|
100
|
+
`${JSON.stringify(proposals.reduce<Settings>(addHook, {}), null, 2)}\n`
|
|
101
|
+
|
|
102
|
+
const isOwnKitNew = (input: HooksInput): boolean =>
|
|
103
|
+
input.kitNewRecorded !== undefined && input.kitNew.kind === 'file' && input.kitNew.checksum === input.kitNewRecorded
|
|
104
|
+
|
|
105
|
+
const planWithoutProposal = (input: HooksInput): Pick<HooksPlan, 'kitNew' | 'kitNewRecorded'> => ({
|
|
106
|
+
kitNew: isOwnKitNew(input) ? { kind: 'remove' } : { kind: 'none' },
|
|
107
|
+
kitNewRecorded: undefined,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const planProposal = (
|
|
111
|
+
input: HooksInput,
|
|
112
|
+
proposals: HookDefinition[],
|
|
113
|
+
): Pick<HooksPlan, 'kitNew' | 'kitNewRecorded'> & { written: boolean } => {
|
|
114
|
+
const content = proposalContent(proposals)
|
|
115
|
+
const contentChecksum = checksum(content)
|
|
116
|
+
if (input.kitNew.kind === 'file' && input.kitNew.checksum === contentChecksum) {
|
|
117
|
+
return { kitNew: { kind: 'none' }, kitNewRecorded: contentChecksum, written: true }
|
|
118
|
+
}
|
|
119
|
+
if (input.kitNew.kind === 'missing' || isOwnKitNew(input)) {
|
|
120
|
+
return { kitNew: { kind: 'write', content }, kitNewRecorded: contentChecksum, written: true }
|
|
121
|
+
}
|
|
122
|
+
return { kitNew: { kind: 'none' }, kitNewRecorded: input.kitNewRecorded, written: false }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A conflict counts only once its proposal is on disk; otherwise it is blocked and nothing is recorded. */
|
|
126
|
+
const settleConflict = (decision: HookDecision, written: boolean): HookDecision => {
|
|
127
|
+
if (decision.outcome !== HOOK_OUTCOME.conflict || !decision.proposal || !decision.entry) return decision
|
|
128
|
+
if (!written) return { ...decision, outcome: HOOK_OUTCOME.blocked }
|
|
129
|
+
return { ...decision, entry: { base: decision.entry.base, pending: hookKey(decision.proposal) } }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const applyToSettings = (settings: Settings, decision: HookDecision): Settings => {
|
|
133
|
+
const withoutOld = decision.remove ? removeHook(settings, decision.remove) : settings
|
|
134
|
+
return decision.add ? addHook(withoutOld, decision.add) : withoutOld
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const settingsChanged = (before: Settings, after: Settings): boolean =>
|
|
138
|
+
canonicalJson(before.hooks ?? {}) !== canonicalJson(after.hooks ?? {})
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Merges the kit hooks into the project settings. Pure. Only handlers recorded in the manifest
|
|
142
|
+
* (or byte-identical to a kit hook) are ever removed; every other entry keeps its content. A kit
|
|
143
|
+
* hook the user removed or edited is not put back unless `accept` is set.
|
|
144
|
+
*/
|
|
145
|
+
export const planHooks = (input: HooksInput): HooksPlan => {
|
|
146
|
+
const names = [...new Set([...Object.keys(input.kit), ...Object.keys(input.entries)])].sort()
|
|
147
|
+
const decided = names.map((name) => applyAccept(decide(name, input), input))
|
|
148
|
+
const proposals = decided.flatMap((decision) =>
|
|
149
|
+
decision.outcome === HOOK_OUTCOME.conflict && decision.proposal ? [decision.proposal] : [],
|
|
150
|
+
)
|
|
151
|
+
const proposal =
|
|
152
|
+
proposals.length > 0 ? planProposal(input, proposals) : { ...planWithoutProposal(input), written: true }
|
|
153
|
+
const settled = decided.map((decision) => settleConflict(decision, proposal.written))
|
|
154
|
+
const settings = settled.reduce(applyToSettings, input.settings)
|
|
155
|
+
const entries = Object.fromEntries(settled.flatMap(({ name, entry }) => (entry ? [[name, entry]] : [])))
|
|
156
|
+
return {
|
|
157
|
+
settings,
|
|
158
|
+
changed: settingsChanged(input.settings, settings),
|
|
159
|
+
entries,
|
|
160
|
+
outcomes: settled.map(({ name, outcome }) => ({ name, outcome })),
|
|
161
|
+
kitNew: proposal.kitNew,
|
|
162
|
+
kitNewRecorded: proposal.kitNewRecorded,
|
|
163
|
+
notAcceptable: input.accept && !settled.some(({ outcome }) => outcome === HOOK_OUTCOME.accepted),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { basename, dirname, join, sep } from 'node:path'
|
|
4
|
+
import { CLAUDE_DIRECTORY } from '../claude/paths'
|
|
5
|
+
import { TOOL_DIRECTORY } from '../config/location'
|
|
6
|
+
import { checksum } from './checksum'
|
|
7
|
+
import { FILE_MODE, type FileMode, type LocalState } from './plan-file'
|
|
8
|
+
|
|
9
|
+
const PERMISSIONS: Record<FileMode, number> = { [FILE_MODE.regular]: 0o644, [FILE_MODE.executable]: 0o755 }
|
|
10
|
+
|
|
11
|
+
const isMissing = (err: unknown): boolean =>
|
|
12
|
+
typeof err === 'object' && err !== null && 'code' in err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')
|
|
13
|
+
|
|
14
|
+
const lstatOrMissing = async (path: string) => {
|
|
15
|
+
try {
|
|
16
|
+
return await lstat(path)
|
|
17
|
+
} catch (err) {
|
|
18
|
+
if (isMissing(err)) return undefined
|
|
19
|
+
throw err
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* True when every directory between `.claude/` and the path is a real directory or not there yet.
|
|
25
|
+
* A symlinked directory would let a write inside `.claude/` land anywhere on disk.
|
|
26
|
+
*/
|
|
27
|
+
const hasPlainAncestors = async (directory: string, path: string): Promise<boolean> => {
|
|
28
|
+
const segments = path.split('/').slice(0, -1)
|
|
29
|
+
for (let i = 1; i <= segments.length; i++) {
|
|
30
|
+
const entry = await lstatOrMissing(join(directory, ...segments.slice(0, i)))
|
|
31
|
+
if (!entry) return true
|
|
32
|
+
if (!entry.isDirectory()) return false
|
|
33
|
+
}
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `notFile` for a directory, a symlink, or a path behind a symlinked directory. */
|
|
38
|
+
export const readLocalState = async (directory: string, path: string): Promise<LocalState> => {
|
|
39
|
+
if (!(await hasPlainAncestors(directory, path))) return { kind: 'notFile' }
|
|
40
|
+
const target = join(directory, path)
|
|
41
|
+
const entry = await lstatOrMissing(target)
|
|
42
|
+
if (!entry) return { kind: 'missing' }
|
|
43
|
+
if (!entry.isFile()) return { kind: 'notFile' }
|
|
44
|
+
return { kind: 'file', checksum: checksum(await readFile(target, 'utf8')) }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The file's text, `undefined` when it does not exist; throws for anything but a regular file. */
|
|
48
|
+
export const readRegularFile = async (directory: string, path: string): Promise<string | undefined> => {
|
|
49
|
+
const state = await readLocalState(directory, path)
|
|
50
|
+
if (state.kind === 'missing') return undefined
|
|
51
|
+
if (state.kind === 'notFile') throw new Error(`${basename(directory)}/${path} is not a regular file`)
|
|
52
|
+
return readFile(join(directory, path), 'utf8')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const assertWritable = async (directory: string, path: string): Promise<void> => {
|
|
56
|
+
if ((await readLocalState(directory, path)).kind === 'notFile') {
|
|
57
|
+
throw new Error(`Refusing to write ${basename(directory)}/${path}: not a regular file or behind a symlink`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Writes through a temporary file and a rename, so a crash never leaves half a file behind.
|
|
63
|
+
* The target is re-checked right before the write; a symlink or directory there is refused.
|
|
64
|
+
*/
|
|
65
|
+
export const writeFileSafely = async (
|
|
66
|
+
directory: string,
|
|
67
|
+
path: string,
|
|
68
|
+
content: string,
|
|
69
|
+
mode: FileMode,
|
|
70
|
+
): Promise<void> => {
|
|
71
|
+
await assertWritable(directory, path)
|
|
72
|
+
const target = join(directory, path)
|
|
73
|
+
await mkdir(dirname(target), { recursive: true })
|
|
74
|
+
const temporary = `${target}.jackherd-${randomBytes(6).toString('hex')}.tmp`
|
|
75
|
+
try {
|
|
76
|
+
await writeFile(temporary, content, { flag: 'wx', mode: PERMISSIONS[mode] })
|
|
77
|
+
await rename(temporary, target)
|
|
78
|
+
} finally {
|
|
79
|
+
await rm(temporary, { force: true })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const removeFileSafely = async (directory: string, path: string): Promise<void> => {
|
|
84
|
+
const state = await readLocalState(directory, path)
|
|
85
|
+
if (state.kind === 'notFile') throw new Error(`Refusing to remove ${basename(directory)}/${path}: not a regular file`)
|
|
86
|
+
if (state.kind === 'file') await rm(join(directory, path))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Creates the file only if nothing is at the path yet; returns false and writes nothing otherwise. */
|
|
90
|
+
export const createFileExclusively = async (directory: string, path: string, content: string): Promise<boolean> => {
|
|
91
|
+
if ((await readLocalState(directory, path)).kind !== 'missing') return false
|
|
92
|
+
const target = join(directory, path)
|
|
93
|
+
await mkdir(dirname(target), { recursive: true })
|
|
94
|
+
try {
|
|
95
|
+
await writeFile(target, content, { flag: 'wx', mode: PERMISSIONS[FILE_MODE.regular] })
|
|
96
|
+
return true
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (typeof err === 'object' && err !== null && 'code' in err && err.code === 'EEXIST') return false
|
|
99
|
+
throw err
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const assertDirectoryInProject = async (projectRoot: string, name: string): Promise<void> => {
|
|
104
|
+
const directory = join(projectRoot, name)
|
|
105
|
+
const entry = await lstatOrMissing(directory)
|
|
106
|
+
if (!entry?.isSymbolicLink()) return
|
|
107
|
+
const [target, root] = await Promise.all([realpath(directory), realpath(projectRoot)])
|
|
108
|
+
if (!target.startsWith(`${root}${sep}`)) {
|
|
109
|
+
throw new Error(`Refusing to follow ${name}/: it is a symlink to ${target}, outside the project`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A symlinked `.claude/` or `.jackherd/` is followed only when it resolves inside the project; otherwise init and
|
|
115
|
+
* sync would write kit files, hooks and the lock wherever the link points, e.g. the user's global directories.
|
|
116
|
+
*/
|
|
117
|
+
export const assertOwnDirectoriesInProject = async (projectRoot: string): Promise<void> => {
|
|
118
|
+
await assertDirectoryInProject(projectRoot, CLAUDE_DIRECTORY)
|
|
119
|
+
await assertDirectoryInProject(projectRoot, TOOL_DIRECTORY)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A project sync or init never runs in the home directory: its `.claude/` is the global install, which a
|
|
124
|
+
* project run would fill without the prefix, give the kit's hooks in the user's `settings.json`, and a
|
|
125
|
+
* `.gitignore` in `~`. Compared after resolving symlinks.
|
|
126
|
+
*/
|
|
127
|
+
export const assertNotHomeDirectory = async (projectRoot: string, home: string): Promise<void> => {
|
|
128
|
+
const [root, homeDirectory] = await Promise.all([realpath(projectRoot), realpath(home)])
|
|
129
|
+
if (root === homeDirectory) {
|
|
130
|
+
throw new Error('Refusing to treat the home directory as a project; use `--global` to install into ~/.claude')
|
|
131
|
+
}
|
|
132
|
+
}
|