jackherd 0.0.1 → 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 +1121 -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 +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,120 @@
|
|
|
1
|
+
import { posix } from 'node:path'
|
|
2
|
+
import { CLAUDE_DIRECTORY, SETTINGS_FILE } from '../claude/paths'
|
|
3
|
+
import { type JackherdConfig, parseConfigFile } from '../config'
|
|
4
|
+
import { type RepositoryFacts, withDetected } from '../config/detect'
|
|
5
|
+
import { CONFIG_FILE, TOOL_DIRECTORY } from '../config/location'
|
|
6
|
+
import { checksum } from './checksum'
|
|
7
|
+
import { errorMessage } from './error-message'
|
|
8
|
+
import type { KitSource } from './kit'
|
|
9
|
+
import { parseKitHooks } from './kit-hooks'
|
|
10
|
+
import { parseKitPath } from './kit-path'
|
|
11
|
+
import type { Manifest } from './manifest'
|
|
12
|
+
import { FILE_MODE, FILE_OUTCOME, type FilePlan, type RenderedFile } from './plan-file'
|
|
13
|
+
import type { HooksPlan } from './plan-hooks'
|
|
14
|
+
import type { HookDefinition } from './settings-hooks'
|
|
15
|
+
|
|
16
|
+
export type AcceptRequest = { files: Set<string>; settings: boolean }
|
|
17
|
+
|
|
18
|
+
const SETTINGS_ACCEPT_PATHS: readonly string[] = [SETTINGS_FILE, `${CLAUDE_DIRECTORY}/${SETTINGS_FILE}`]
|
|
19
|
+
|
|
20
|
+
export const parseJson = (text: string, file: string, location: string = TOOL_DIRECTORY): unknown => {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(text)
|
|
23
|
+
} catch (err) {
|
|
24
|
+
throw new Error(`${location}/${file} is not valid JSON: ${errorMessage(err)}`)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `location` names the config's directory in messages: `.jackherd` in a project, `~/.jackherd` for a global install.
|
|
30
|
+
* With `facts`, the values the file leaves out are detected from that repository (see `withDetected`).
|
|
31
|
+
*/
|
|
32
|
+
export const parseConfig = (
|
|
33
|
+
text: string,
|
|
34
|
+
location: string = TOOL_DIRECTORY,
|
|
35
|
+
facts?: RepositoryFacts,
|
|
36
|
+
): { config: JackherdConfig; notes: string[] } => {
|
|
37
|
+
const raw = parseJson(text, CONFIG_FILE, location)
|
|
38
|
+
const detected = facts ? withDetected(raw, facts) : { raw, notes: [] }
|
|
39
|
+
return { config: parseConfigFile(detected.raw, `${location}/${CONFIG_FILE}`), notes: detected.notes }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const parseAcceptPaths = (raw: string[]): AcceptRequest => {
|
|
43
|
+
const files = new Set<string>()
|
|
44
|
+
let settings = false
|
|
45
|
+
for (const path of raw) {
|
|
46
|
+
if (SETTINGS_ACCEPT_PATHS.includes(path)) {
|
|
47
|
+
settings = true
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
const parsed = parseKitPath(path)
|
|
51
|
+
if (!parsed.ok) throw new Error(`--accept ${path}: ${parsed.reason}`)
|
|
52
|
+
files.add(parsed.path)
|
|
53
|
+
}
|
|
54
|
+
return { files, settings }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Closed whatever the config says, so a tampered `.jackherd/config.json` cannot reopen it. */
|
|
58
|
+
const DEFAULT_WORKTREE_PREFIX = 'worktrees/'
|
|
59
|
+
|
|
60
|
+
/** Lower-case prefixes relative to `.claude/` that hold worktrees: the default and the configured one. */
|
|
61
|
+
const worktreePrefixes = (config: JackherdConfig): string[] => {
|
|
62
|
+
const normalized = posix.normalize(config.worktreeDirectory)
|
|
63
|
+
const parsed = parseKitPath(normalized)
|
|
64
|
+
const configured = parsed.ok && normalized.startsWith(`${CLAUDE_DIRECTORY}/`) ? [`${parsed.path.toLowerCase()}/`] : []
|
|
65
|
+
return [DEFAULT_WORKTREE_PREFIX, ...configured]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Worktrees under `.claude/` hold other checkouts; no kit path, manifest entry or `--accept` may
|
|
70
|
+
* reach into them, whatever checksum a manifest entry claims.
|
|
71
|
+
*/
|
|
72
|
+
export const assertOutsideWorktrees = (paths: Iterable<string>, config: JackherdConfig): void => {
|
|
73
|
+
const prefixes = worktreePrefixes(config)
|
|
74
|
+
const inside = [...paths].filter((path) => prefixes.some((prefix) => path.toLowerCase().startsWith(prefix)))
|
|
75
|
+
if (inside.length > 0) {
|
|
76
|
+
throw new Error(`Refusing paths inside the worktree directory ${config.worktreeDirectory}: ${inside.join(', ')}`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const renderKitFiles = (kit: KitSource, render: (template: string) => string): Map<string, RenderedFile> => {
|
|
81
|
+
const rendered = new Map<string, RenderedFile>()
|
|
82
|
+
for (const file of kit.files) {
|
|
83
|
+
const parsed = parseKitPath(file.path)
|
|
84
|
+
if (!parsed.ok) throw new Error(`Kit file ${file.path} ${parsed.reason}`)
|
|
85
|
+
if (rendered.has(parsed.path)) throw new Error(`Kit file ${parsed.path} appears twice`)
|
|
86
|
+
const content = render(file.template)
|
|
87
|
+
const mode = file.executable ? FILE_MODE.executable : FILE_MODE.regular
|
|
88
|
+
rendered.set(parsed.path, { content, checksum: checksum(content), mode })
|
|
89
|
+
}
|
|
90
|
+
return rendered
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const renderKitHooks = (
|
|
94
|
+
kit: KitSource,
|
|
95
|
+
render: (template: string) => string,
|
|
96
|
+
): Record<string, HookDefinition> => {
|
|
97
|
+
if (kit.hooksTemplate === undefined) return {}
|
|
98
|
+
const parsed = parseKitHooks(render(kit.hooksTemplate))
|
|
99
|
+
if (!parsed.ok) throw new Error(`Kit hooks are invalid: ${parsed.reason}`)
|
|
100
|
+
return parsed.hooks
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Every `--accept` must land on something to accept; otherwise the run stops before any write. */
|
|
104
|
+
export const assertAcceptsApply = (accept: AcceptRequest, files: FilePlan[], hooks: HooksPlan): void => {
|
|
105
|
+
const accepted = new Set(files.filter(({ outcome }) => outcome === FILE_OUTCOME.accepted).map(({ path }) => path))
|
|
106
|
+
const refused = [...accept.files].filter((path) => !accepted.has(path))
|
|
107
|
+
if (accept.settings && hooks.notAcceptable) refused.push(`${CLAUDE_DIRECTORY}/${SETTINGS_FILE}`)
|
|
108
|
+
if (refused.length > 0) {
|
|
109
|
+
throw new Error(`Nothing to accept for: ${refused.join(', ')} (no conflict, no deselected kit file or hook there)`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const nextManifest = (kitVersion: string, files: FilePlan[], hooks: HooksPlan): Manifest => {
|
|
114
|
+
const manifest: Manifest = {
|
|
115
|
+
kitVersion,
|
|
116
|
+
files: Object.fromEntries(files.flatMap(({ path, entry }) => (entry ? [[path, entry]] : []))),
|
|
117
|
+
hooks: hooks.entries,
|
|
118
|
+
}
|
|
119
|
+
return hooks.kitNewRecorded === undefined ? manifest : { ...manifest, settingsKitNew: hooks.kitNewRecorded }
|
|
120
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { FileOutcome } from './plan-file'
|
|
2
|
+
import type { HookOutcome } from './plan-hooks'
|
|
3
|
+
|
|
4
|
+
export type FileReport = { path: string; outcome: FileOutcome }
|
|
5
|
+
|
|
6
|
+
export type HookReport = { name: string; outcome: HookOutcome }
|
|
7
|
+
|
|
8
|
+
/** `notes` name the config values the repository did not decide, printed before the summary. */
|
|
9
|
+
export type SyncResult = { files: FileReport[]; hooks: HookReport[]; notes?: string[] }
|
package/src/sync/sync.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { CLAUDE_DIRECTORY, SETTINGS_FILE } from '../claude/paths'
|
|
4
|
+
import type { JackherdConfig } from '../config'
|
|
5
|
+
import type { RepositoryFacts } from '../config/detect'
|
|
6
|
+
import { CONFIG_FILE, MANIFEST_FILE, TOOL_DIRECTORY } from '../config/location'
|
|
7
|
+
import { readRepositoryFacts } from '../config/repository-facts'
|
|
8
|
+
import { REPOSITORY_TOKENS } from '../config/repository-values'
|
|
9
|
+
import { CLI_NAME } from '../version/release'
|
|
10
|
+
import { ensureGitignoreLines } from './gitignore'
|
|
11
|
+
import { toGlobalKit, toProjectKit } from './global-kit'
|
|
12
|
+
import type { KitSource } from './kit'
|
|
13
|
+
import { kitNewPath, toolRootPath } from './kit-path'
|
|
14
|
+
import { moveGlobalLegacyFiles, moveProjectLegacyFiles, removeEmptyLegacyDirectories } from './legacy-files'
|
|
15
|
+
import { EMPTY_MANIFEST, type Manifest, parseManifest, serializeManifest } from './manifest'
|
|
16
|
+
import { FILE_MODE, type FileEffect, type FilePlan, planFile, type RenderedFile } from './plan-file'
|
|
17
|
+
import { type HooksPlan, planHooks } from './plan-hooks'
|
|
18
|
+
import {
|
|
19
|
+
assertNotHomeDirectory,
|
|
20
|
+
assertOwnDirectoriesInProject,
|
|
21
|
+
readLocalState,
|
|
22
|
+
readRegularFile,
|
|
23
|
+
removeFileSafely,
|
|
24
|
+
writeFileSafely,
|
|
25
|
+
} from './project-fs'
|
|
26
|
+
import { renderTemplate } from './render'
|
|
27
|
+
import { parseSettings, type Settings } from './settings-hooks'
|
|
28
|
+
import {
|
|
29
|
+
type AcceptRequest,
|
|
30
|
+
assertAcceptsApply,
|
|
31
|
+
assertOutsideWorktrees,
|
|
32
|
+
nextManifest,
|
|
33
|
+
parseAcceptPaths,
|
|
34
|
+
parseConfig,
|
|
35
|
+
parseJson,
|
|
36
|
+
renderKitFiles,
|
|
37
|
+
renderKitHooks,
|
|
38
|
+
} from './sync-plan'
|
|
39
|
+
import type { SyncResult } from './sync-result'
|
|
40
|
+
|
|
41
|
+
/** A sync's result with the config it was read from, so a caller can name what the config decided. */
|
|
42
|
+
export type ConfiguredSync = { result: SyncResult; config: JackherdConfig }
|
|
43
|
+
|
|
44
|
+
export type SyncRequest = {
|
|
45
|
+
projectRoot: string
|
|
46
|
+
/** The user's home directory, which is never a project; defaults to the real one. */
|
|
47
|
+
home?: string
|
|
48
|
+
kit: KitSource
|
|
49
|
+
/** Paths from `--accept`, relative to `.claude/` or to the project root. */
|
|
50
|
+
accept: string[]
|
|
51
|
+
/** What fills the values the config leaves out; read from the project root when absent. */
|
|
52
|
+
facts?: RepositoryFacts
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type GlobalSyncRequest = {
|
|
56
|
+
/** The directory holding the user's `.claude/`, normally the home directory. */
|
|
57
|
+
home: string
|
|
58
|
+
kit: KitSource
|
|
59
|
+
/** Paths from `--accept`, relative to `~/.claude/`. */
|
|
60
|
+
accept: string[]
|
|
61
|
+
/** `XDG_CONFIG_HOME` as the command read it, where the old public-content facts lie; undefined for `~/.config`. */
|
|
62
|
+
configHome?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Where a lock path lies on disk: the directory to read and write it in, and the path below it. */
|
|
66
|
+
type Location = { directory: string; path: string }
|
|
67
|
+
|
|
68
|
+
type Locate = (path: string) => Location
|
|
69
|
+
|
|
70
|
+
/** What the plan is built from once the target decided how to read its config and settings. */
|
|
71
|
+
type PlanInput = {
|
|
72
|
+
claudeDirectory: string
|
|
73
|
+
toolDirectory: string
|
|
74
|
+
locate: Locate
|
|
75
|
+
kit: KitSource
|
|
76
|
+
config: JackherdConfig
|
|
77
|
+
accept: string[]
|
|
78
|
+
readHooks: (manifest: Manifest, accept: AcceptRequest) => Promise<HooksPlan>
|
|
79
|
+
/** Placeholders rendered as something other than their config value (see `renderTemplate`). */
|
|
80
|
+
overrides?: ReadonlyMap<string, string>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type SyncPlan = { files: FilePlan[]; hooks: HooksPlan; manifest: Manifest; manifestText: string | undefined }
|
|
84
|
+
|
|
85
|
+
const SETTINGS_KIT_NEW = kitNewPath(SETTINGS_FILE)
|
|
86
|
+
|
|
87
|
+
const GLOBAL_TOOL_LOCATION = `~/${TOOL_DIRECTORY}`
|
|
88
|
+
|
|
89
|
+
/** `location` names the directory in messages; `initCommand` is what creates the config there. */
|
|
90
|
+
const readConfig = async (
|
|
91
|
+
toolDirectory: string,
|
|
92
|
+
location: string,
|
|
93
|
+
initCommand: string,
|
|
94
|
+
facts?: RepositoryFacts,
|
|
95
|
+
): Promise<{ config: JackherdConfig; notes: string[] }> => {
|
|
96
|
+
const text = await readRegularFile(toolDirectory, CONFIG_FILE)
|
|
97
|
+
if (text === undefined) throw new Error(`No ${location}/${CONFIG_FILE} found. Run \`${initCommand}\` first.`)
|
|
98
|
+
return parseConfig(text, location, facts)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const readManifest = async (toolDirectory: string): Promise<{ manifest: Manifest; text: string | undefined }> => {
|
|
102
|
+
const text = await readRegularFile(toolDirectory, MANIFEST_FILE)
|
|
103
|
+
if (text === undefined) return { manifest: EMPTY_MANIFEST, text }
|
|
104
|
+
const parsed = parseManifest(parseJson(text, MANIFEST_FILE))
|
|
105
|
+
if (!parsed.ok) throw new Error(`${TOOL_DIRECTORY}/${MANIFEST_FILE} is invalid:\n${parsed.reason}`)
|
|
106
|
+
return { manifest: parsed.manifest, text }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const readSettings = async (claudeDirectory: string): Promise<Settings> => {
|
|
110
|
+
const text = await readRegularFile(claudeDirectory, SETTINGS_FILE)
|
|
111
|
+
if (text === undefined) return {}
|
|
112
|
+
const parsed = parseSettings(parseJson(text, SETTINGS_FILE, CLAUDE_DIRECTORY))
|
|
113
|
+
if (!parsed.ok) throw new Error(`.claude/${SETTINGS_FILE} has hooks sync cannot read:\n${parsed.reason}`)
|
|
114
|
+
return parsed.settings
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A project keeps every kit file in `.claude/`. */
|
|
118
|
+
const inClaudeDirectory =
|
|
119
|
+
(claudeDirectory: string): Locate =>
|
|
120
|
+
(path) => ({ directory: claudeDirectory, path })
|
|
121
|
+
|
|
122
|
+
/** A global install keeps the tool root's files in `~/.jackherd/`, everything else in `~/.claude/`. */
|
|
123
|
+
const inGlobalDirectories =
|
|
124
|
+
(claudeDirectory: string, toolDirectory: string): Locate =>
|
|
125
|
+
(path) => {
|
|
126
|
+
const inTool = toolRootPath(path)
|
|
127
|
+
return inTool === null ? { directory: claudeDirectory, path } : { directory: toolDirectory, path: inTool }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const localStateAt = ({ directory, path }: Location) => readLocalState(directory, path)
|
|
131
|
+
|
|
132
|
+
const planFiles = async (
|
|
133
|
+
locate: Locate,
|
|
134
|
+
rendered: Map<string, RenderedFile>,
|
|
135
|
+
manifest: Manifest,
|
|
136
|
+
accept: AcceptRequest,
|
|
137
|
+
): Promise<FilePlan[]> => {
|
|
138
|
+
const paths = [...new Set([...rendered.keys(), ...Object.keys(manifest.files)])].sort()
|
|
139
|
+
return Promise.all(
|
|
140
|
+
paths.map(async (path) =>
|
|
141
|
+
planFile({
|
|
142
|
+
path,
|
|
143
|
+
rendered: rendered.get(path) ?? null,
|
|
144
|
+
entry: manifest.files[path],
|
|
145
|
+
local: await localStateAt(locate(path)),
|
|
146
|
+
kitNew: await localStateAt(locate(kitNewPath(path))),
|
|
147
|
+
accept: accept.files.has(path),
|
|
148
|
+
}),
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const buildPlan = async ({
|
|
154
|
+
toolDirectory,
|
|
155
|
+
locate,
|
|
156
|
+
kit,
|
|
157
|
+
config,
|
|
158
|
+
accept: acceptPaths,
|
|
159
|
+
readHooks,
|
|
160
|
+
overrides,
|
|
161
|
+
}: PlanInput): Promise<SyncPlan> => {
|
|
162
|
+
const { manifest, text: manifestText } = await readManifest(toolDirectory)
|
|
163
|
+
const accept = parseAcceptPaths(acceptPaths)
|
|
164
|
+
const render = (template: string) => renderTemplate(template, config, overrides)
|
|
165
|
+
const rendered = renderKitFiles(kit, render)
|
|
166
|
+
assertOutsideWorktrees([...rendered.keys(), ...Object.keys(manifest.files), ...accept.files], config)
|
|
167
|
+
const files = await planFiles(locate, rendered, manifest, accept)
|
|
168
|
+
const hooks = await readHooks(manifest, accept)
|
|
169
|
+
assertAcceptsApply(accept, files, hooks)
|
|
170
|
+
return { files, hooks, manifest: nextManifest(kit.version, files, hooks), manifestText }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Merges the kit's hooks into the project's `settings.json`. */
|
|
174
|
+
const projectHooks =
|
|
175
|
+
(claudeDirectory: string, kit: KitSource, config: JackherdConfig) =>
|
|
176
|
+
async (manifest: Manifest, accept: AcceptRequest): Promise<HooksPlan> =>
|
|
177
|
+
planHooks({
|
|
178
|
+
kit: renderKitHooks(kit, (template) => renderTemplate(template, config)),
|
|
179
|
+
entries: manifest.hooks,
|
|
180
|
+
settings: await readSettings(claudeDirectory),
|
|
181
|
+
kitNew: await readLocalState(claudeDirectory, SETTINGS_KIT_NEW),
|
|
182
|
+
kitNewRecorded: manifest.settingsKitNew,
|
|
183
|
+
accept: accept.settings,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
/** A global install ships no hooks and never reads the user's `settings.json`. */
|
|
187
|
+
const noHooks = async (_manifest: Manifest, accept: AcceptRequest): Promise<HooksPlan> =>
|
|
188
|
+
planHooks({
|
|
189
|
+
kit: {},
|
|
190
|
+
entries: {},
|
|
191
|
+
settings: {},
|
|
192
|
+
kitNew: { kind: 'missing' },
|
|
193
|
+
kitNewRecorded: undefined,
|
|
194
|
+
accept: accept.settings,
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const applyFileEffect = (locate: Locate, path: string, effect: FileEffect): Promise<void> => {
|
|
198
|
+
const { directory, path: target } = locate(effect.target === 'file' ? path : kitNewPath(path))
|
|
199
|
+
if (effect.kind === 'remove') return removeFileSafely(directory, target)
|
|
200
|
+
return writeFileSafely(directory, target, effect.content, effect.mode)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Writes before removals, so a file that moved (an old global path to the tool root) is never missing in between. */
|
|
204
|
+
const effectsInOrder = (files: FilePlan[]): { path: string; effect: FileEffect }[] => {
|
|
205
|
+
const all = files.flatMap(({ path, effects }) => effects.map((effect) => ({ path, effect })))
|
|
206
|
+
return [
|
|
207
|
+
...all.filter(({ effect }) => effect.kind !== 'remove'),
|
|
208
|
+
...all.filter(({ effect }) => effect.kind === 'remove'),
|
|
209
|
+
]
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const applyHooks = async (claudeDirectory: string, hooks: HooksPlan): Promise<void> => {
|
|
213
|
+
if (hooks.changed) {
|
|
214
|
+
await writeFileSafely(
|
|
215
|
+
claudeDirectory,
|
|
216
|
+
SETTINGS_FILE,
|
|
217
|
+
`${JSON.stringify(hooks.settings, null, 2)}\n`,
|
|
218
|
+
FILE_MODE.regular,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
if (hooks.kitNew.kind === 'write')
|
|
222
|
+
await writeFileSafely(claudeDirectory, SETTINGS_KIT_NEW, hooks.kitNew.content, FILE_MODE.regular)
|
|
223
|
+
if (hooks.kitNew.kind === 'remove') await removeFileSafely(claudeDirectory, SETTINGS_KIT_NEW)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The manifest is written last: a run that dies halfway leaves files whose content equals the
|
|
228
|
+
* kit version, which the next run adopts as unchanged instead of mistaking them for local edits.
|
|
229
|
+
*/
|
|
230
|
+
const applyPlan = async (
|
|
231
|
+
{ claudeDirectory, toolDirectory, locate }: Pick<PlanInput, 'claudeDirectory' | 'toolDirectory' | 'locate'>,
|
|
232
|
+
plan: SyncPlan,
|
|
233
|
+
): Promise<void> => {
|
|
234
|
+
for (const { path, effect } of effectsInOrder(plan.files)) await applyFileEffect(locate, path, effect)
|
|
235
|
+
await applyHooks(claudeDirectory, plan.hooks)
|
|
236
|
+
const manifestText = serializeManifest(plan.manifest)
|
|
237
|
+
if (manifestText !== plan.manifestText)
|
|
238
|
+
await writeFileSafely(toolDirectory, MANIFEST_FILE, manifestText, FILE_MODE.regular)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const syncResultOf = (plan: SyncPlan): SyncResult => ({
|
|
242
|
+
files: plan.files.map(({ path, outcome }) => ({ path, outcome })),
|
|
243
|
+
hooks: plan.hooks.outcomes,
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Brings the project's `.claude/` in line with the kit and records it in `.jackherd/lock.json`. Everything is read, validated and planned
|
|
248
|
+
* before the first write, so a refusal (invalid config, manifest or settings, a bad path, an
|
|
249
|
+
* `--accept` with nothing to accept) throws and leaves the project exactly as it was.
|
|
250
|
+
*/
|
|
251
|
+
export const syncProjectWithConfig = async (request: SyncRequest): Promise<ConfiguredSync> => {
|
|
252
|
+
await assertNotHomeDirectory(request.projectRoot, request.home ?? homedir())
|
|
253
|
+
await assertOwnDirectoriesInProject(request.projectRoot)
|
|
254
|
+
const claudeDirectory = join(request.projectRoot, CLAUDE_DIRECTORY)
|
|
255
|
+
const toolDirectory = join(request.projectRoot, TOOL_DIRECTORY)
|
|
256
|
+
const moved = await moveProjectLegacyFiles(request.projectRoot)
|
|
257
|
+
const facts = request.facts ?? readRepositoryFacts(request.projectRoot)
|
|
258
|
+
const { config, notes } = await readConfig(toolDirectory, TOOL_DIRECTORY, `${CLI_NAME} init`, facts)
|
|
259
|
+
const directories = { claudeDirectory, toolDirectory, locate: inClaudeDirectory(claudeDirectory) }
|
|
260
|
+
const plan = await buildPlan({
|
|
261
|
+
...directories,
|
|
262
|
+
kit: toProjectKit(request.kit, config.skillPrefix),
|
|
263
|
+
config,
|
|
264
|
+
accept: request.accept,
|
|
265
|
+
readHooks: projectHooks(claudeDirectory, request.kit, config),
|
|
266
|
+
})
|
|
267
|
+
await applyPlan(directories, plan)
|
|
268
|
+
await ensureGitignoreLines(request.projectRoot)
|
|
269
|
+
return { result: { ...syncResultOf(plan), notes: [...moved, ...notes] }, config }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export const runSync = async (request: SyncRequest): Promise<SyncResult> =>
|
|
273
|
+
(await syncProjectWithConfig(request)).result
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Installs the kit into the user's `~/.claude/` with every skill and agent renamed by `skillPrefix`
|
|
277
|
+
* (see `toGlobalKit`). Same plan-then-write and manifest rules as a project sync, against
|
|
278
|
+
* `~/.jackherd/config.json` and `~/.jackherd/lock.json`; no hooks, no `settings.json`, no
|
|
279
|
+
* `.gitignore`. Files the kit never wrote stay untouched; one at a kit path becomes a conflict.
|
|
280
|
+
*/
|
|
281
|
+
export const syncGlobalWithConfig = async (request: GlobalSyncRequest): Promise<ConfiguredSync> => {
|
|
282
|
+
await assertOwnDirectoriesInProject(request.home)
|
|
283
|
+
const claudeDirectory = join(request.home, CLAUDE_DIRECTORY)
|
|
284
|
+
const toolDirectory = join(request.home, TOOL_DIRECTORY)
|
|
285
|
+
const moved = await moveGlobalLegacyFiles(request.home, request.configHome)
|
|
286
|
+
const { config } = await readConfig(toolDirectory, GLOBAL_TOOL_LOCATION, `${CLI_NAME} init --global`)
|
|
287
|
+
if (config.skillPrefix === '') {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`${GLOBAL_TOOL_LOCATION}/${CONFIG_FILE} has no skillPrefix; a global install needs one (e.g. "kit-") so it cannot shadow project skills`,
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
const directories = { claudeDirectory, toolDirectory, locate: inGlobalDirectories(claudeDirectory, toolDirectory) }
|
|
293
|
+
const plan = await buildPlan({
|
|
294
|
+
...directories,
|
|
295
|
+
kit: toGlobalKit(request.kit, config.skillPrefix),
|
|
296
|
+
config,
|
|
297
|
+
accept: request.accept,
|
|
298
|
+
readHooks: noHooks,
|
|
299
|
+
overrides: REPOSITORY_TOKENS,
|
|
300
|
+
})
|
|
301
|
+
await applyPlan(directories, plan)
|
|
302
|
+
await removeEmptyLegacyDirectories(claudeDirectory)
|
|
303
|
+
return { result: { ...syncResultOf(plan), notes: moved }, config }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export const runGlobalSync = async (request: GlobalSyncRequest): Promise<SyncResult> =>
|
|
307
|
+
(await syncGlobalWithConfig(request)).result
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
/** What a run of an external program answered; `missing` is true when there is no such program to run at all. */
|
|
4
|
+
export type ToolRun = { exitCode: number; output: string; missing: boolean }
|
|
5
|
+
|
|
6
|
+
/** Runs one external program with arguments, never through a shell. */
|
|
7
|
+
export type RunTool = (binary: string, args: string[]) => ToolRun
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The external programs the kit works with, as far as they are installed; `herdrInstallable` is true where the
|
|
11
|
+
* official herdr installer applies (Linux and macOS).
|
|
12
|
+
*/
|
|
13
|
+
export type ToolPresence = { herdr: boolean; gh: boolean; herdrInstallable: boolean }
|
|
14
|
+
|
|
15
|
+
type Environment = Record<string, string | undefined>
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The installers the kit may run after a yes: each tool's official script, HTTPS-only on every redirect. The script
|
|
19
|
+
* is downloaded whole into a temporary file before `sh` runs it, so a download cut off midway runs nothing and a
|
|
20
|
+
* failed download ends with curl's exit code. Wrapped in `sh -c`, the command is also safe to paste into any
|
|
21
|
+
* interactive shell: its `exit` ends only the inner shell. herdr's applies to Linux and macOS.
|
|
22
|
+
*/
|
|
23
|
+
export const INSTALLERS = {
|
|
24
|
+
herdr:
|
|
25
|
+
'sh -c \'installer=$(mktemp) && curl --proto =https --tlsv1.2 -fsSL https://herdr.dev/install.sh -o "$installer" && sh "$installer"; code=$?; rm -f "$installer"; exit $code\'',
|
|
26
|
+
} as const
|
|
27
|
+
|
|
28
|
+
export type InstallerName = keyof typeof INSTALLERS
|
|
29
|
+
|
|
30
|
+
/** The binary herdr names in `HERDR_BIN_PATH`, else `herdr` from the PATH. */
|
|
31
|
+
export const herdrBinary = (env: Environment): string => env.HERDR_BIN_PATH || 'herdr'
|
|
32
|
+
|
|
33
|
+
const INSTALLABLE_PLATFORMS: readonly NodeJS.Platform[] = ['linux', 'darwin']
|
|
34
|
+
|
|
35
|
+
const isPresent = (run: RunTool, binary: string): boolean => !run(binary, ['--version']).missing
|
|
36
|
+
|
|
37
|
+
/** Which tools are installed, asked by running them; a program that runs at all counts, whatever it answers. */
|
|
38
|
+
export const detectTools = (env: Environment, platform: NodeJS.Platform, run: RunTool): ToolPresence => ({
|
|
39
|
+
herdr: isPresent(run, herdrBinary(env)),
|
|
40
|
+
gh: isPresent(run, 'gh'),
|
|
41
|
+
herdrInstallable: INSTALLABLE_PLATFORMS.includes(platform),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/** `env` is passed on because Bun looks the binary up in it. */
|
|
45
|
+
export const spawnTool =
|
|
46
|
+
(env: Environment): RunTool =>
|
|
47
|
+
(binary, args) => {
|
|
48
|
+
const result = spawnSync(binary, args, { encoding: 'utf-8', env })
|
|
49
|
+
const output = `${result.error?.message ?? ''} ${result.stdout ?? ''}${result.stderr ?? ''}`.trim()
|
|
50
|
+
const missing = (result.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT'
|
|
51
|
+
return { exitCode: result.status ?? 1, output, missing }
|
|
52
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { herdrBinary } from '../sync/tools'
|
|
2
|
+
import type { AdoptedName, LaneAgent, LaneStatusSource } from './types'
|
|
3
|
+
|
|
4
|
+
type HerdrAgent = { name?: string; pane_id: string; agent_status: string; agent_session?: unknown; cwd?: unknown }
|
|
5
|
+
|
|
6
|
+
const METADATA_SOURCE = 'pf-plans'
|
|
7
|
+
|
|
8
|
+
const isHerdrAgent = (value: unknown): value is HerdrAgent => {
|
|
9
|
+
if (typeof value !== 'object' || value === null) return false
|
|
10
|
+
const candidate = value as Record<string, unknown>
|
|
11
|
+
return (
|
|
12
|
+
typeof candidate.pane_id === 'string' &&
|
|
13
|
+
typeof candidate.agent_status === 'string' &&
|
|
14
|
+
(candidate.name === undefined || typeof candidate.name === 'string')
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const sessionOf = ({ agent_session }: HerdrAgent): string | null => {
|
|
19
|
+
if (typeof agent_session !== 'object' || agent_session === null) return null
|
|
20
|
+
const { value } = agent_session as Record<string, unknown>
|
|
21
|
+
return typeof value === 'string' && value !== '' ? value : null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const laneAgentOf = (agent: HerdrAgent, name: string): LaneAgent => ({
|
|
25
|
+
name,
|
|
26
|
+
pane: agent.pane_id,
|
|
27
|
+
status: agent.agent_status,
|
|
28
|
+
session: sessionOf(agent),
|
|
29
|
+
cwd: typeof agent.cwd === 'string' ? agent.cwd : null,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Reads the JSON of `herdr agent list`; throws on anything else, so a changed format is seen, not guessed.
|
|
34
|
+
* Agents without a name are left out, except the one in `adopt.pane`, which takes `adopt.name`.
|
|
35
|
+
*/
|
|
36
|
+
export const parseAgentList = (json: string, adopt?: AdoptedName): LaneAgent[] => {
|
|
37
|
+
const agents = (JSON.parse(json) as { result?: { agents?: unknown } }).result?.agents
|
|
38
|
+
if (!Array.isArray(agents) || !agents.every(isHerdrAgent)) throw new Error('herdr agent list: unexpected answer')
|
|
39
|
+
return agents.flatMap((agent) => {
|
|
40
|
+
const name = agent.name || (agent.pane_id === adopt?.pane ? adopt.name : '')
|
|
41
|
+
return name ? [laneAgentOf(agent, name)] : []
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sets or, with null, clears the sidebar title of a pane. The pane id goes before the options:
|
|
47
|
+
* herdr 0.9.0 rejects it after them, despite `--help`.
|
|
48
|
+
*/
|
|
49
|
+
export const titleArguments = (pane: string, title: string | null): string[] => [
|
|
50
|
+
'pane',
|
|
51
|
+
'report-metadata',
|
|
52
|
+
pane,
|
|
53
|
+
'--source',
|
|
54
|
+
METADATA_SOURCE,
|
|
55
|
+
...(title ? ['--title', title] : ['--clear-title']),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
/** Runs the herdr CLI with these arguments and returns its stdout; throws when it fails. */
|
|
59
|
+
export type HerdrRunner = (args: string[]) => string
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Runs the herdr binary of `herdrBinary` (`HERDR_BIN_PATH`, else `herdr` from the PATH) and returns its stdout;
|
|
63
|
+
* throws with herdr's own error text when it fails or is not running.
|
|
64
|
+
*/
|
|
65
|
+
export const herdrRunner =
|
|
66
|
+
(env: Record<string, string | undefined>): HerdrRunner =>
|
|
67
|
+
(args) => {
|
|
68
|
+
const result = Bun.spawnSync([herdrBinary(env), ...args], { stdout: 'pipe', stderr: 'pipe', env })
|
|
69
|
+
if (result.exitCode !== 0) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`herdr ${args.slice(0, 2).join(' ')}: ${result.stderr.toString().trim() || `exit ${result.exitCode}`}`,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
return result.stdout.toString()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const runHerdr: HerdrRunner = herdrRunner(process.env)
|
|
78
|
+
|
|
79
|
+
export const createHerdrLaneStatusSource = (run: HerdrRunner): LaneStatusSource => ({
|
|
80
|
+
listAgents: (adopt) => parseAgentList(run(['agent', 'list']), adopt),
|
|
81
|
+
setTitle: (pane, title) => {
|
|
82
|
+
try {
|
|
83
|
+
run(titleArguments(pane, title))
|
|
84
|
+
return null
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return `${pane}: ${error instanceof Error ? error.message : String(error)}`
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
export const herdrLaneStatusSource = createHerdrLaneStatusSource(runHerdr)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { TERMINAL_ADAPTER, type TerminalAdapter } from '../config'
|
|
2
|
+
import { herdrLaneStatusSource } from './herdr'
|
|
3
|
+
import { noneLaneStatusSource } from './none'
|
|
4
|
+
import type { LaneStatusSource } from './types'
|
|
5
|
+
|
|
6
|
+
export type { AdoptedName, LaneAgent, LaneStatusSource } from './types'
|
|
7
|
+
|
|
8
|
+
const SOURCES: Record<TerminalAdapter, LaneStatusSource> = {
|
|
9
|
+
[TERMINAL_ADAPTER.none]: noneLaneStatusSource,
|
|
10
|
+
[TERMINAL_ADAPTER.herdr]: herdrLaneStatusSource,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const laneStatusSourceFor = (adapter: TerminalAdapter): LaneStatusSource => SOURCES[adapter]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** What opening something in the default application needs from the machine; tests pass their own. */
|
|
2
|
+
export type OpenEnvironment = {
|
|
3
|
+
platform: NodeJS.Platform
|
|
4
|
+
env: Record<string, string | undefined>
|
|
5
|
+
start: (command: string, args: string[]) => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const OPENERS: Partial<Record<NodeJS.Platform, string>> = { darwin: 'open', linux: 'xdg-open' }
|
|
9
|
+
|
|
10
|
+
/** The command that opens a file or a link in the default application of the platform, null when there is none. */
|
|
11
|
+
export const openerFor = (platform: NodeJS.Platform): string | null => OPENERS[platform] ?? null
|
|
12
|
+
|
|
13
|
+
/** Linux opens things in a graphical session only; elsewhere the platform's opener is assumed to work. */
|
|
14
|
+
export const hasDisplay = ({ platform, env }: Pick<OpenEnvironment, 'platform' | 'env'>): boolean =>
|
|
15
|
+
platform !== 'linux' || Boolean(env.DISPLAY || env.WAYLAND_DISPLAY)
|
|
16
|
+
|
|
17
|
+
const systemEnvironment = (): OpenEnvironment => ({
|
|
18
|
+
platform: process.platform,
|
|
19
|
+
env: process.env,
|
|
20
|
+
start: (command, args) => {
|
|
21
|
+
Bun.spawn([command, ...args], { stdout: 'ignore', stderr: 'ignore' })
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Opens a file or a link in the default application. Returns the reason when that was not possible (no
|
|
27
|
+
* opener for the platform, no display, the opener could not be started), null when it was started.
|
|
28
|
+
*/
|
|
29
|
+
export const openInBrowser = (target: string, environment: OpenEnvironment = systemEnvironment()): string | null => {
|
|
30
|
+
const opener = openerFor(environment.platform)
|
|
31
|
+
if (!opener) return `no way to open ${target} on ${environment.platform}`
|
|
32
|
+
if (!hasDisplay(environment)) return 'no display'
|
|
33
|
+
try {
|
|
34
|
+
environment.start(opener, [target])
|
|
35
|
+
return null
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return `${opener} ${target}: ${error instanceof Error ? error.message : String(error)}`
|
|
38
|
+
}
|
|
39
|
+
}
|