@vladpazych/dexter 0.1.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/bin/dexter +6 -0
- package/package.json +43 -0
- package/src/claude/index.ts +6 -0
- package/src/cli.ts +39 -0
- package/src/env/define.ts +190 -0
- package/src/env/index.ts +10 -0
- package/src/env/loader.ts +61 -0
- package/src/env/print.ts +98 -0
- package/src/env/validate.ts +46 -0
- package/src/index.ts +16 -0
- package/src/meta/adapters/fs.ts +22 -0
- package/src/meta/adapters/git.ts +29 -0
- package/src/meta/adapters/glob.ts +14 -0
- package/src/meta/adapters/index.ts +24 -0
- package/src/meta/adapters/process.ts +40 -0
- package/src/meta/cli.ts +340 -0
- package/src/meta/domain/bisect.ts +126 -0
- package/src/meta/domain/blame.ts +136 -0
- package/src/meta/domain/commit.ts +135 -0
- package/src/meta/domain/commits.ts +23 -0
- package/src/meta/domain/constraints/registry.ts +49 -0
- package/src/meta/domain/constraints/types.ts +30 -0
- package/src/meta/domain/diff.ts +34 -0
- package/src/meta/domain/eval.ts +57 -0
- package/src/meta/domain/format.ts +34 -0
- package/src/meta/domain/lint.ts +88 -0
- package/src/meta/domain/pickaxe.ts +99 -0
- package/src/meta/domain/quality.ts +145 -0
- package/src/meta/domain/rules.ts +21 -0
- package/src/meta/domain/scope-context.ts +63 -0
- package/src/meta/domain/service.ts +68 -0
- package/src/meta/domain/setup.ts +34 -0
- package/src/meta/domain/test.ts +72 -0
- package/src/meta/domain/transcripts.ts +88 -0
- package/src/meta/domain/typecheck.ts +41 -0
- package/src/meta/domain/workspace.ts +78 -0
- package/src/meta/errors.ts +19 -0
- package/src/meta/hooks/on-post-read.ts +61 -0
- package/src/meta/hooks/on-post-write.ts +65 -0
- package/src/meta/hooks/on-pre-bash.ts +69 -0
- package/src/meta/hooks/stubs.ts +51 -0
- package/src/meta/index.ts +36 -0
- package/src/meta/lib/actor.ts +53 -0
- package/src/meta/lib/eslint.ts +58 -0
- package/src/meta/lib/format.ts +55 -0
- package/src/meta/lib/paths.ts +36 -0
- package/src/meta/lib/present.ts +231 -0
- package/src/meta/lib/spec-links.ts +83 -0
- package/src/meta/lib/stdin.ts +56 -0
- package/src/meta/ports.ts +50 -0
- package/src/meta/types.ts +113 -0
- package/src/output/build.ts +56 -0
- package/src/output/index.ts +24 -0
- package/src/output/output.test.ts +374 -0
- package/src/output/render-cli.ts +55 -0
- package/src/output/render-json.ts +80 -0
- package/src/output/render-md.ts +43 -0
- package/src/output/render-xml.ts +55 -0
- package/src/output/render.ts +23 -0
- package/src/output/types.ts +44 -0
- package/src/pipe/format.ts +167 -0
- package/src/pipe/index.ts +4 -0
- package/src/pipe/parse.ts +131 -0
- package/src/pipe/spawn.ts +205 -0
- package/src/pipe/types.ts +27 -0
- package/src/terminal/colors.ts +95 -0
- package/src/terminal/index.ts +16 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { basename } from "node:path"
|
|
2
|
+
|
|
3
|
+
import { block, text, render } from "../../output/index.ts"
|
|
4
|
+
import { readJsonStdin, getFilePath, type HookInput } from "../lib/stdin.ts"
|
|
5
|
+
import { findRepoRoot } from "../lib/paths.ts"
|
|
6
|
+
import { isSpecFile, findBrokenLinks, formatBrokenLinks } from "../lib/spec-links.ts"
|
|
7
|
+
|
|
8
|
+
function xml(name: string, content: string): string {
|
|
9
|
+
return render(block(name, text(content)), "xml")
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function onPostRead(): Promise<void> {
|
|
13
|
+
const input = await readJsonStdin<HookInput>()
|
|
14
|
+
const filePath = getFilePath(input)
|
|
15
|
+
|
|
16
|
+
if (!filePath) return
|
|
17
|
+
|
|
18
|
+
let root: string
|
|
19
|
+
try {
|
|
20
|
+
root = findRepoRoot()
|
|
21
|
+
} catch {
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sections: string[] = []
|
|
26
|
+
|
|
27
|
+
if (isSpecFile(filePath)) {
|
|
28
|
+
try {
|
|
29
|
+
const content = await Bun.file(filePath).text()
|
|
30
|
+
const broken = findBrokenLinks(filePath, content, root)
|
|
31
|
+
|
|
32
|
+
if (broken.length > 0) {
|
|
33
|
+
sections.push(xml("spec-links", formatBrokenLinks(broken)))
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
// File might have been deleted or unreadable
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const name = basename(filePath)
|
|
41
|
+
if (name === "CLAUDE.md") {
|
|
42
|
+
sections.push(xml("before-writing", "load /markdown skill · llm audience · claude-md document"))
|
|
43
|
+
} else if (name === "README.md") {
|
|
44
|
+
sections.push(xml("before-writing", "load /markdown skill · human audience · readme document"))
|
|
45
|
+
} else if (name === "SKILL.md") {
|
|
46
|
+
sections.push(xml("before-writing", "load /markdown skill · llm audience · skill document"))
|
|
47
|
+
} else if (name === "OOUX.md") {
|
|
48
|
+
sections.push(xml("before-writing", "load /ooux skill before modifying OOUX spec"))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (sections.length > 0) {
|
|
52
|
+
console.log(
|
|
53
|
+
JSON.stringify({
|
|
54
|
+
hookSpecificOutput: {
|
|
55
|
+
hookEventName: "PostToolUse",
|
|
56
|
+
additionalContext: sections.join("\n") + "\n",
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { block, text, render } from "../../output/index.ts"
|
|
2
|
+
import { readJsonStdin, getFilePath, type HookInput } from "../lib/stdin.ts"
|
|
3
|
+
import { shouldLint, runESLint } from "../lib/eslint.ts"
|
|
4
|
+
import { findRepoRoot, isInsideRepo } from "../lib/paths.ts"
|
|
5
|
+
import { isSpecFile, findBrokenLinks, formatBrokenLinks } from "../lib/spec-links.ts"
|
|
6
|
+
|
|
7
|
+
function xml(name: string, content: string): string {
|
|
8
|
+
return render(block(name, text(content)), "xml")
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function onPostWrite(): Promise<void> {
|
|
12
|
+
const input = await readJsonStdin<HookInput>()
|
|
13
|
+
const filePath = getFilePath(input)
|
|
14
|
+
if (!filePath) return
|
|
15
|
+
|
|
16
|
+
let root: string
|
|
17
|
+
try {
|
|
18
|
+
root = findRepoRoot()
|
|
19
|
+
} catch {
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!isInsideRepo(filePath, root)) return
|
|
24
|
+
|
|
25
|
+
const sections: string[] = []
|
|
26
|
+
|
|
27
|
+
if (shouldLint(filePath)) {
|
|
28
|
+
try {
|
|
29
|
+
const remaining = runESLint(filePath)
|
|
30
|
+
if (remaining) {
|
|
31
|
+
sections.push(xml("lint", remaining))
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// ESLint might not be available or fail
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (isSpecFile(filePath)) {
|
|
39
|
+
try {
|
|
40
|
+
const content = await Bun.file(filePath).text()
|
|
41
|
+
const broken = findBrokenLinks(filePath, content, root)
|
|
42
|
+
|
|
43
|
+
if (broken.length > 0) {
|
|
44
|
+
sections.push(xml("spec-links", formatBrokenLinks(broken)))
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// File might have been deleted or unreadable
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
sections.push(xml("commit", `commit in meaningful chunks · ./meta/run commit "<reason>" <files>`))
|
|
52
|
+
|
|
53
|
+
if (sections.length > 0) {
|
|
54
|
+
console.log(
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
hookSpecificOutput: {
|
|
57
|
+
hookEventName: "PostToolUse",
|
|
58
|
+
additionalContext: sections.join("\n"),
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
process.exit(0)
|
|
65
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readJsonStdin, getCommand, type HookInput } from "../lib/stdin.ts"
|
|
2
|
+
|
|
3
|
+
/** Patterns to deny with hints */
|
|
4
|
+
const DENY_PATTERNS = [
|
|
5
|
+
{ pattern: /^git\s+(add)(\s|$)/, hint: "Use: /commit skill" },
|
|
6
|
+
{ pattern: /^git\s+commit(?!\s+--no-verify)(\s|$)/, hint: "Use: /commit skill" },
|
|
7
|
+
{ pattern: /^git\s+push\s+.*--force/, hint: "Force push not allowed. Use revert instead." },
|
|
8
|
+
{ pattern: /^git\s+reset\s+--hard/, hint: "Hard reset not allowed. Use revert instead." },
|
|
9
|
+
{ pattern: /^git\s+clean\s+-f/, hint: "git clean -f not allowed. Remove files explicitly." },
|
|
10
|
+
{ pattern: /^git\s+checkout\s+\.$/, hint: "Discard all changes not allowed. Be explicit about files." },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
async function isInConflictResolution(): Promise<boolean> {
|
|
14
|
+
try {
|
|
15
|
+
const result = Bun.spawnSync(["git", "rev-parse", "--git-dir"], {
|
|
16
|
+
stdout: "pipe",
|
|
17
|
+
stderr: "pipe",
|
|
18
|
+
})
|
|
19
|
+
if (!result.success) return false
|
|
20
|
+
const gitDir = result.stdout.toString().trim()
|
|
21
|
+
|
|
22
|
+
const conflictIndicators = [
|
|
23
|
+
`${gitDir}/rebase-merge`,
|
|
24
|
+
`${gitDir}/rebase-apply`,
|
|
25
|
+
`${gitDir}/MERGE_HEAD`,
|
|
26
|
+
`${gitDir}/CHERRY_PICK_HEAD`,
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
for (const indicator of conflictIndicators) {
|
|
30
|
+
if (await Bun.file(indicator).exists()) {
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return false
|
|
36
|
+
} catch {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function onPreBash(): Promise<void> {
|
|
42
|
+
const input = await readJsonStdin<HookInput>()
|
|
43
|
+
const command = getCommand(input)
|
|
44
|
+
|
|
45
|
+
if (!command) {
|
|
46
|
+
process.exit(0)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const { pattern, hint } of DENY_PATTERNS) {
|
|
50
|
+
if (pattern.test(command)) {
|
|
51
|
+
if (await isInConflictResolution()) {
|
|
52
|
+
process.exit(0)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
hookSpecificOutput: {
|
|
58
|
+
hookEventName: "PreToolUse",
|
|
59
|
+
permissionDecision: "deny",
|
|
60
|
+
permissionDecisionReason: `Raw git command blocked: ${command}. ${hint}`,
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
)
|
|
64
|
+
process.exit(0)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
process.exit(0)
|
|
69
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stub hook handlers — silent exit 0.
|
|
3
|
+
*
|
|
4
|
+
* Extension points for consumer repos to override via createCLI hooks config.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readJsonStdin, type HookInput } from "../lib/stdin.ts"
|
|
8
|
+
|
|
9
|
+
export async function onSessionStart(): Promise<void> {}
|
|
10
|
+
|
|
11
|
+
export async function onPostBash(): Promise<void> {
|
|
12
|
+
await readJsonStdin<HookInput>()
|
|
13
|
+
process.exit(0)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function onStop(): Promise<void> {}
|
|
17
|
+
|
|
18
|
+
export async function onPromptSubmit(): Promise<void> {
|
|
19
|
+
await readJsonStdin<HookInput>()
|
|
20
|
+
process.exit(0)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function onNotification(): Promise<void> {
|
|
24
|
+
await readJsonStdin<HookInput>()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function onPreCompact(): Promise<void> {
|
|
28
|
+
await readJsonStdin<HookInput>()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function onToolFailure(): Promise<void> {
|
|
32
|
+
await readJsonStdin<HookInput>()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function onSubagentStart(): Promise<void> {
|
|
36
|
+
await readJsonStdin<HookInput>()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function onSubagentStop(): Promise<void> {
|
|
40
|
+
await readJsonStdin<HookInput>()
|
|
41
|
+
process.exit(0)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function onSessionEnd(): Promise<void> {
|
|
45
|
+
await readJsonStdin<HookInput>()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function onPermissionRequest(): Promise<void> {
|
|
49
|
+
await readJsonStdin<HookInput>()
|
|
50
|
+
process.exit(0)
|
|
51
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meta framework — createCLI factory, hook protocol, domain commands.
|
|
3
|
+
*
|
|
4
|
+
* Consumer repos use this to wire project-specific commands and hook extensions
|
|
5
|
+
* on top of the universal agentic development toolkit.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Factory
|
|
9
|
+
export { createCLI } from "./cli.ts"
|
|
10
|
+
export type { CLIConfig, HookContext, HookOutput } from "./cli.ts"
|
|
11
|
+
|
|
12
|
+
// Error handling
|
|
13
|
+
export { ControlError, isControlError } from "./errors.ts"
|
|
14
|
+
|
|
15
|
+
// Utilities for custom commands
|
|
16
|
+
export { parseFormat } from "./lib/format.ts"
|
|
17
|
+
export type { ParsedFormat } from "./lib/format.ts"
|
|
18
|
+
export { findRepoRoot } from "./lib/paths.ts"
|
|
19
|
+
export { getActor, isLLM, isHuman } from "./lib/actor.ts"
|
|
20
|
+
|
|
21
|
+
// Types
|
|
22
|
+
export type { HookInput } from "./lib/stdin.ts"
|
|
23
|
+
export type {
|
|
24
|
+
CommitParams,
|
|
25
|
+
CommitResult,
|
|
26
|
+
QueryResult,
|
|
27
|
+
GitResult,
|
|
28
|
+
Package,
|
|
29
|
+
EvalParams,
|
|
30
|
+
EvalResult,
|
|
31
|
+
SetupResult,
|
|
32
|
+
TranscriptsParams,
|
|
33
|
+
TranscriptsResult,
|
|
34
|
+
} from "./types.ts"
|
|
35
|
+
export type { ControlPorts, GitPort, FsPort, ProcessPort, GlobPort } from "./ports.ts"
|
|
36
|
+
export type { ControlService } from "./domain/service.ts"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actor detection and mode constraints.
|
|
3
|
+
*
|
|
4
|
+
* CLAUDECODE=1 indicates LLM agent context.
|
|
5
|
+
* Absence indicates human context.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type Actor = "llm" | "human"
|
|
9
|
+
|
|
10
|
+
/** Detect current actor from environment */
|
|
11
|
+
export function getActor(): Actor {
|
|
12
|
+
return process.env.CLAUDECODE === "1" ? "llm" : "human"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** True if running in LLM agent context */
|
|
16
|
+
export function isLLM(): boolean {
|
|
17
|
+
return getActor() === "llm"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** True if running in human context */
|
|
21
|
+
export function isHuman(): boolean {
|
|
22
|
+
return getActor() === "human"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Output mode constraints by actor.
|
|
27
|
+
*
|
|
28
|
+
* LLM: minimal output, inline only, never TUI/interactive
|
|
29
|
+
* Human: supports inline and interactive
|
|
30
|
+
*/
|
|
31
|
+
export interface ActorOutputMode {
|
|
32
|
+
/** Use minimal output (no decorations, progress bars, etc.) */
|
|
33
|
+
minimal: boolean
|
|
34
|
+
/** Allow interactive prompts */
|
|
35
|
+
interactive: boolean
|
|
36
|
+
/** Allow TUI (full-screen terminal UI) */
|
|
37
|
+
tui: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getOutputMode(): ActorOutputMode {
|
|
41
|
+
if (isLLM()) {
|
|
42
|
+
return {
|
|
43
|
+
minimal: true,
|
|
44
|
+
interactive: false,
|
|
45
|
+
tui: false,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
minimal: false,
|
|
50
|
+
interactive: true,
|
|
51
|
+
tui: true,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint integration for post-write hooks.
|
|
3
|
+
*
|
|
4
|
+
* Auto-fixes a file, returns remaining (unfixable) violations as text.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { dirname, join } from "node:path"
|
|
8
|
+
import { existsSync } from "node:fs"
|
|
9
|
+
|
|
10
|
+
/** Walk up from filePath to find the nearest directory containing package.json */
|
|
11
|
+
function findPackageRoot(filePath: string): string | undefined {
|
|
12
|
+
let dir = dirname(filePath)
|
|
13
|
+
while (dir !== dirname(dir)) {
|
|
14
|
+
if (existsSync(join(dir, "package.json"))) return dir
|
|
15
|
+
dir = dirname(dir)
|
|
16
|
+
}
|
|
17
|
+
return undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Auto-fix a file, return remaining violations as text (empty string = clean) */
|
|
21
|
+
export function runESLint(filePath: string): string {
|
|
22
|
+
const cwd = findPackageRoot(filePath)
|
|
23
|
+
const result = Bun.spawnSync(["bunx", "eslint", "--fix", "--no-warn-ignored", filePath], {
|
|
24
|
+
stdout: "pipe",
|
|
25
|
+
stderr: "pipe",
|
|
26
|
+
...(cwd && { cwd }),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// eslint prints remaining (unfixable) violations to stdout
|
|
30
|
+
return result.stdout.toString().trim()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Check if a file should be linted */
|
|
34
|
+
export function shouldLint(filePath: string): boolean {
|
|
35
|
+
if (!filePath.endsWith(".ts") && !filePath.endsWith(".tsx")) {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const skipPatterns = [
|
|
40
|
+
"/node_modules/",
|
|
41
|
+
"/dist/",
|
|
42
|
+
"/.next/",
|
|
43
|
+
"/coverage/",
|
|
44
|
+
".d.ts",
|
|
45
|
+
"/routeTree.gen.ts",
|
|
46
|
+
"/storybook-static/",
|
|
47
|
+
"/.storybook/",
|
|
48
|
+
"/.vite/",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
for (const pattern of skipPatterns) {
|
|
52
|
+
if (filePath.includes(pattern)) {
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format flag parsing — extracts output mode from CLI args.
|
|
3
|
+
*
|
|
4
|
+
* Supports `--format cli|json|xml|md` and `--json` shorthand.
|
|
5
|
+
* Format flags must appear before `--` separator.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { OutputMode } from "../../output/types.ts"
|
|
9
|
+
|
|
10
|
+
const VALID_MODES = new Set<OutputMode>(["cli", "json", "xml", "md"])
|
|
11
|
+
|
|
12
|
+
export type ParsedFormat = {
|
|
13
|
+
mode: OutputMode
|
|
14
|
+
rest: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extract format flag from args, return mode + remaining args.
|
|
19
|
+
*
|
|
20
|
+
* Scans only args before `--` separator. Default mode: `cli`.
|
|
21
|
+
*/
|
|
22
|
+
export function parseFormat(args: string[]): ParsedFormat {
|
|
23
|
+
let mode: OutputMode = "cli"
|
|
24
|
+
const rest: string[] = []
|
|
25
|
+
const ddIndex = args.indexOf("--")
|
|
26
|
+
const flagRegion = ddIndex >= 0 ? ddIndex : args.length
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < args.length; i++) {
|
|
29
|
+
if (i < flagRegion) {
|
|
30
|
+
if (args[i] === "--json") {
|
|
31
|
+
mode = "json"
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
if (args[i] === "--xml") {
|
|
35
|
+
mode = "xml"
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
if (args[i] === "--md") {
|
|
39
|
+
mode = "md"
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (args[i] === "--format" && i + 1 < flagRegion) {
|
|
43
|
+
const candidate = args[i + 1] as OutputMode
|
|
44
|
+
if (VALID_MODES.has(candidate)) {
|
|
45
|
+
mode = candidate
|
|
46
|
+
i++ // skip value
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
rest.push(args[i]!)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { mode, rest }
|
|
55
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path utilities for finding repo root.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, realpathSync } from "node:fs"
|
|
6
|
+
import { dirname, join } from "node:path"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Find the repo root by looking for CLAUDE.md, starting from CWD.
|
|
10
|
+
* Returns absolute path or throws if not found.
|
|
11
|
+
*/
|
|
12
|
+
export function findRepoRoot(): string {
|
|
13
|
+
let dir = process.cwd()
|
|
14
|
+
|
|
15
|
+
while (dir !== dirname(dir)) {
|
|
16
|
+
if (existsSync(join(dir, "CLAUDE.md"))) {
|
|
17
|
+
return dir
|
|
18
|
+
}
|
|
19
|
+
dir = dirname(dir)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
throw new Error("error: not in a repo (no CLAUDE.md found)")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if a file path is inside the repo root.
|
|
27
|
+
* Resolves symlinks — files behind symlinks pointing outside are excluded.
|
|
28
|
+
*/
|
|
29
|
+
export function isInsideRepo(filePath: string, root: string): boolean {
|
|
30
|
+
try {
|
|
31
|
+
const real = realpathSync(filePath)
|
|
32
|
+
return real.startsWith(root + "/") || real === root
|
|
33
|
+
} catch {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
}
|