pi-code 1.0.4 → 1.0.6
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/README.md +25 -13
- package/extensions/claude-rules.ts +158 -54
- package/extensions/commands.ts +417 -41
- package/extensions/context-imports.ts +446 -61
- package/extensions/hooks.ts +473 -73
- package/extensions/init.ts +81 -0
- package/extensions/internal/agent-run.ts +42 -0
- package/extensions/internal/bash-rules.ts +27 -0
- package/extensions/internal/command-file.ts +423 -66
- package/extensions/internal/html-markdown.ts +71 -0
- package/extensions/internal/instruction-events.ts +70 -0
- package/extensions/internal/managed-settings.ts +38 -0
- package/extensions/internal/mcp-call.ts +28 -0
- package/extensions/internal/mcp-oauth.ts +177 -0
- package/extensions/internal/model-complete.ts +68 -0
- package/extensions/internal/path-rules.ts +80 -0
- package/extensions/internal/plugins.ts +138 -0
- package/extensions/internal/project-approval.ts +2 -3
- package/extensions/internal/project-root.ts +78 -0
- package/extensions/internal/shell-split.ts +65 -0
- package/extensions/internal/strip-comments.ts +100 -0
- package/extensions/internal/web-transport.ts +3 -1
- package/extensions/mcp.ts +579 -30
- package/extensions/memory.ts +158 -35
- package/extensions/notify.ts +76 -4
- package/extensions/output-styles.ts +34 -6
- package/extensions/plan-mode/utils.ts +3 -57
- package/extensions/question.ts +2 -2
- package/extensions/skills.ts +11 -1
- package/extensions/status-line.ts +100 -5
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +25 -6
- package/extensions/subagent/index.ts +310 -31
- package/extensions/web.ts +93 -15
- package/package.json +1 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upward search for project configuration, shared across extensions.
|
|
3
|
+
*
|
|
4
|
+
* Claude anchors project config (.claude/*, .mcp.json, CLAUDE.local.md) at the
|
|
5
|
+
* project root, so a session started in a subdirectory must still find it. The
|
|
6
|
+
* walk runs from cwd up to the repository root and no further: without the bound,
|
|
7
|
+
* config planted in a world-writable ancestor such as /tmp would be offered to
|
|
8
|
+
* every session beneath it. With no project marker the extent is unknown, so only
|
|
9
|
+
* cwd is considered. The project-approval walk uses the same markers, so whatever
|
|
10
|
+
* these find is exactly what that walk gated.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from 'node:fs'
|
|
14
|
+
import * as path from 'node:path'
|
|
15
|
+
|
|
16
|
+
/** Project root markers ending the walk. `.git` is a file in worktrees and submodules. */
|
|
17
|
+
export const ROOT_MARKERS = ['.git', 'package.json']
|
|
18
|
+
|
|
19
|
+
/** Project root at or above `from`, or undefined when no marker is found. */
|
|
20
|
+
export function repoRoot(from: string): string | undefined {
|
|
21
|
+
let currentDir = from
|
|
22
|
+
while (true) {
|
|
23
|
+
if (ROOT_MARKERS.some((marker) => fs.existsSync(path.join(currentDir, marker)))) return currentDir
|
|
24
|
+
const parentDir = path.dirname(currentDir)
|
|
25
|
+
if (parentDir === currentDir) return undefined
|
|
26
|
+
currentDir = parentDir
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function statOf(target: string): fs.Stats | null {
|
|
31
|
+
try {
|
|
32
|
+
return fs.statSync(target)
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function findNearest(cwd: string, relative: string, wantDir: boolean): string | null {
|
|
39
|
+
const boundary = repoRoot(cwd) ?? cwd
|
|
40
|
+
let currentDir = cwd
|
|
41
|
+
while (true) {
|
|
42
|
+
const candidate = path.join(currentDir, relative)
|
|
43
|
+
const stat = statOf(candidate)
|
|
44
|
+
if (stat && (wantDir ? stat.isDirectory() : stat.isFile())) return candidate
|
|
45
|
+
|
|
46
|
+
if (currentDir === boundary) return null
|
|
47
|
+
const parentDir = path.dirname(currentDir)
|
|
48
|
+
if (parentDir === currentDir) return null
|
|
49
|
+
currentDir = parentDir
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Nearest `relative` directory at or above `cwd`, stopping at the repository root. */
|
|
54
|
+
export function findNearestDir(cwd: string, relative: string): string | null {
|
|
55
|
+
return findNearest(cwd, relative, true)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Nearest `relative` file at or above `cwd`, stopping at the repository root. */
|
|
59
|
+
export function findNearestFile(cwd: string, relative: string): string | null {
|
|
60
|
+
return findNearest(cwd, relative, false)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Every `relative` file between the repository root and cwd, ordered root first,
|
|
64
|
+
* matching Claude's root-down ordering for hierarchy-loaded context. */
|
|
65
|
+
export function ancestorFiles(cwd: string, relative: string): string[] {
|
|
66
|
+
const boundary = repoRoot(cwd) ?? cwd
|
|
67
|
+
const found: string[] = []
|
|
68
|
+
let currentDir = cwd
|
|
69
|
+
while (true) {
|
|
70
|
+
const candidate = path.join(currentDir, relative)
|
|
71
|
+
if (statOf(candidate)?.isFile()) found.push(candidate)
|
|
72
|
+
if (currentDir === boundary) break
|
|
73
|
+
const parentDir = path.dirname(currentDir)
|
|
74
|
+
if (parentDir === currentDir) break
|
|
75
|
+
currentDir = parentDir
|
|
76
|
+
}
|
|
77
|
+
return found.reverse()
|
|
78
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote-aware splitting of a shell command into its top-level segments.
|
|
3
|
+
*
|
|
4
|
+
* Shared by plan mode's bash guard and the commands extension's allowed-tools
|
|
5
|
+
* scope enforcement: both vet each subcommand on its own, and both refuse to
|
|
6
|
+
* guess when the shell could be hiding another command.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// The shell can hide an arbitrary command inside any of these, so callers refuse
|
|
10
|
+
// such a command outright rather than parse it.
|
|
11
|
+
const SUBSTITUTION = /\$\(|`|<\(|>\(/
|
|
12
|
+
|
|
13
|
+
export const hasSubstitution = (command: string): boolean => SUBSTITUTION.test(command)
|
|
14
|
+
|
|
15
|
+
/** Length of the separator at `i`, or 0 when there is none. */
|
|
16
|
+
function separatorAt(command: string, i: number): number {
|
|
17
|
+
const pair = command.slice(i, i + 2)
|
|
18
|
+
if (pair === '&&' || pair === '||' || pair === '|&') return 2
|
|
19
|
+
const ch = command[i]
|
|
20
|
+
return ch === ';' || ch === '|' || ch === '&' || ch === '\n' ? 1 : 0
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Split on the shell separators Claude Code documents (`&&`, `||`, `;`, `|`, `|&`, `&`,
|
|
25
|
+
* newline) so every subcommand is checked on its own, ignoring separators inside quotes:
|
|
26
|
+
* `grep 'a|b'` is one read, not a pipe. Returns nothing on an unbalanced quote, which
|
|
27
|
+
* fails the caller closed rather than guessing at the intended split.
|
|
28
|
+
*
|
|
29
|
+
* A shell AST would be exact; this is the honest approximation for a quoting-only concern.
|
|
30
|
+
*/
|
|
31
|
+
export function splitSegments(command: string): string[] {
|
|
32
|
+
const segments: string[] = []
|
|
33
|
+
let current = ''
|
|
34
|
+
let quote: "'" | '"' | undefined
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < command.length; i++) {
|
|
37
|
+
const ch = command[i]
|
|
38
|
+
if (quote !== undefined) {
|
|
39
|
+
current += ch
|
|
40
|
+
if (ch === quote) quote = undefined
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
if (ch === "'" || ch === '"') {
|
|
44
|
+
quote = ch
|
|
45
|
+
current += ch
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (ch === '\\' && i + 1 < command.length) {
|
|
49
|
+
current += ch + command[++i]
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
const separator = separatorAt(command, i)
|
|
53
|
+
if (separator > 0) {
|
|
54
|
+
segments.push(current)
|
|
55
|
+
current = ''
|
|
56
|
+
i += separator - 1
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
current += ch
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (quote !== undefined) return []
|
|
63
|
+
segments.push(current)
|
|
64
|
+
return segments.map((segment) => segment.trim()).filter(Boolean)
|
|
65
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block-level HTML comment stripping for context and rule files.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code strips block-level HTML comments (`<!-- maintainer notes -->`)
|
|
5
|
+
* from CLAUDE.md files before injection, while preserving comments inside code
|
|
6
|
+
* blocks. "Block-level" is read as whole-line-anchored: a comment counts only
|
|
7
|
+
* when it starts a line (after optional indentation) and nothing but comments
|
|
8
|
+
* and whitespace occupy the line(s) it spans; those lines are removed entirely,
|
|
9
|
+
* a multi-line comment with every line it covers. A comment sharing a line with
|
|
10
|
+
* real content is inline prose and stays verbatim, as does anything inside a
|
|
11
|
+
* fenced code block (backtick or tilde).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The fence a line opens or closes, if any; mirrors context-imports. */
|
|
15
|
+
export function fenceMarker(lineStart: string): string | null {
|
|
16
|
+
if (lineStart.startsWith('```')) return '`'
|
|
17
|
+
if (lineStart.startsWith('~~~')) return '~'
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Length of the fence run starting `lineStart`, a run of `marker` characters. */
|
|
22
|
+
function fenceLength(lineStart: string, marker: string): number {
|
|
23
|
+
let length = 0
|
|
24
|
+
while (length < lineStart.length && lineStart[length] === marker) length++
|
|
25
|
+
return length
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// CommonMark closes a fenced block only with a fence of the same character that
|
|
29
|
+
// is at least as long as the opener, so both are tracked: a shorter same-char
|
|
30
|
+
// fence line (the classic 3-backtick block quoted inside a 4-backtick one) is
|
|
31
|
+
// content, not a closer.
|
|
32
|
+
interface Fence {
|
|
33
|
+
marker: string
|
|
34
|
+
length: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The fence state after a line, plus whether the line is fenced code (opener,
|
|
38
|
+
* body, or closer) and so emitted verbatim rather than scanned for comments. */
|
|
39
|
+
function stepFence(fence: Fence | null, trimmed: string, marker: string | null): { fence: Fence | null; fenced: boolean } {
|
|
40
|
+
if (marker !== null && fence === null) {
|
|
41
|
+
return { fence: { marker, length: fenceLength(trimmed, marker) }, fenced: true }
|
|
42
|
+
}
|
|
43
|
+
if (fence !== null) {
|
|
44
|
+
const closes = marker === fence.marker && fenceLength(trimmed, marker) >= fence.length
|
|
45
|
+
return { fence: closes ? null : fence, fenced: true } // closer included: comments are content, not maintainer notes
|
|
46
|
+
}
|
|
47
|
+
return { fence, fenced: false }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Advance a line while inside an open multi-line comment. Emits any content that
|
|
51
|
+
* trails the closer onto its own line; returns whether the comment stays open. */
|
|
52
|
+
function continueOpenComment(out: string[], line: string): boolean {
|
|
53
|
+
const close = line.indexOf('-->')
|
|
54
|
+
if (close === -1) return true // still inside the comment: the line goes with it
|
|
55
|
+
const rest = line.slice(close + 3)
|
|
56
|
+
// Content trailing the closer keeps its line; a bare closer line is dropped.
|
|
57
|
+
if (rest.trim().length > 0) out.push(rest.trimStart())
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Fold line-starting HTML comments off `trimmed`; several may share one line.
|
|
62
|
+
* `allComment` means the line held nothing but comments and whitespace, so it is
|
|
63
|
+
* dropped; `opensBlock` means the last comment opened a multi-line block. */
|
|
64
|
+
function consumeLineComments(trimmed: string): { allComment: boolean; opensBlock: boolean } {
|
|
65
|
+
let rest = trimmed
|
|
66
|
+
let sawComment = false
|
|
67
|
+
while (rest.startsWith('<!--')) {
|
|
68
|
+
sawComment = true
|
|
69
|
+
const close = rest.indexOf('-->')
|
|
70
|
+
if (close === -1) return { allComment: true, opensBlock: true } // opens a multi-line comment
|
|
71
|
+
rest = rest.slice(close + 3).trimStart()
|
|
72
|
+
}
|
|
73
|
+
return { allComment: sawComment && rest.length === 0, opensBlock: false }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Remove whole-line HTML comments, keeping fenced code and inline comments. */
|
|
77
|
+
export function stripBlockComments(text: string): string {
|
|
78
|
+
const out: string[] = []
|
|
79
|
+
let fence: Fence | null = null
|
|
80
|
+
let inComment = false
|
|
81
|
+
for (const line of text.split('\n')) {
|
|
82
|
+
if (inComment) {
|
|
83
|
+
inComment = continueOpenComment(out, line)
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
const trimmed = line.trimStart()
|
|
87
|
+
const step = stepFence(fence, trimmed, fenceMarker(trimmed))
|
|
88
|
+
fence = step.fence
|
|
89
|
+
if (step.fenced) {
|
|
90
|
+
out.push(line)
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
const { allComment, opensBlock } = consumeLineComments(trimmed)
|
|
94
|
+
if (opensBlock) inComment = true
|
|
95
|
+
if (allComment) continue // the whole line was comment
|
|
96
|
+
// No comment, or a line-starting comment followed by content: inline, verbatim.
|
|
97
|
+
out.push(line)
|
|
98
|
+
}
|
|
99
|
+
return out.join('\n')
|
|
100
|
+
}
|
|
@@ -32,7 +32,9 @@ export function httpFetch(url: URL, opts: TransportOptions): Promise<Response> {
|
|
|
32
32
|
url,
|
|
33
33
|
{
|
|
34
34
|
method: 'GET',
|
|
35
|
-
|
|
35
|
+
// Prefer markdown, as Claude's WebFetch does, so a content-negotiating
|
|
36
|
+
// server can return markdown directly and skip the lossy HTML conversion.
|
|
37
|
+
headers: { 'User-Agent': opts.userAgent, Accept: 'text/markdown, text/html;q=0.9, */*;q=0.8' },
|
|
36
38
|
signal: opts.signal,
|
|
37
39
|
lookup: opts.lookup,
|
|
38
40
|
// servername is left to default to url.hostname, so SNI and certificate
|