pi-code 0.1.0 → 0.2.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/README.md +24 -7
- package/extensions/claude-rules.ts +7 -3
- package/extensions/context-imports.ts +56 -29
- package/extensions/git-checkpoint.ts +16 -16
- package/extensions/hooks.ts +2 -1
- package/extensions/mcp.ts +24 -10
- package/extensions/memory.ts +15 -4
- package/extensions/notify.ts +6 -4
- package/extensions/output-styles.ts +1 -1
- package/extensions/plan-mode/index.ts +83 -58
- package/extensions/plan-mode/utils.ts +76 -16
- package/extensions/project-trust.ts +65 -0
- package/extensions/question.ts +63 -49
- package/extensions/subagent/agents.ts +12 -11
- package/extensions/subagent/index.ts +609 -465
- package/extensions/todo.ts +48 -31
- package/extensions/web.ts +49 -27
- package/package.json +1 -1
|
@@ -27,7 +27,7 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
27
27
|
/\bpip\s+(install|uninstall)/i,
|
|
28
28
|
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
|
|
29
29
|
/\bbrew\s+(install|uninstall|upgrade)/i,
|
|
30
|
-
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-
|
|
30
|
+
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-d|stash|cherry-pick|revert|tag|init|clone)/i,
|
|
31
31
|
/\bsudo\b/i,
|
|
32
32
|
/\bsu\b/i,
|
|
33
33
|
/\bkill\b/i,
|
|
@@ -40,7 +40,9 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
40
40
|
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
41
41
|
]
|
|
42
42
|
|
|
43
|
-
// Safe read-only commands allowed in plan mode
|
|
43
|
+
// Safe read-only commands allowed in plan mode. Deliberately excludes env/printenv
|
|
44
|
+
// (secret disclosure, and env is an exec wrapper), curl/wget (fetch plus -o writes),
|
|
45
|
+
// awk (system()) and sed (w/W/e write even under -n).
|
|
44
46
|
const SAFE_PATTERNS = [
|
|
45
47
|
/^\s*cat\b/,
|
|
46
48
|
/^\s*head\b/,
|
|
@@ -65,8 +67,6 @@ const SAFE_PATTERNS = [
|
|
|
65
67
|
/^\s*which\b/,
|
|
66
68
|
/^\s*whereis\b/,
|
|
67
69
|
/^\s*type\b/,
|
|
68
|
-
/^\s*env\b/,
|
|
69
|
-
/^\s*printenv\b/,
|
|
70
70
|
/^\s*uname\b/,
|
|
71
71
|
/^\s*whoami\b/,
|
|
72
72
|
/^\s*id\b/,
|
|
@@ -83,21 +83,44 @@ const SAFE_PATTERNS = [
|
|
|
83
83
|
/^\s*yarn\s+(list|info|why|audit)/i,
|
|
84
84
|
/^\s*node\s+--version/i,
|
|
85
85
|
/^\s*python\s+--version/i,
|
|
86
|
-
/^\s*curl\s/i,
|
|
87
|
-
/^\s*wget\s+-O\s*-/i,
|
|
88
86
|
/^\s*jq\b/,
|
|
89
|
-
/^\s*sed\s+-n/i,
|
|
90
|
-
/^\s*awk\b/,
|
|
91
87
|
/^\s*rg\b/,
|
|
92
88
|
/^\s*fd\b/,
|
|
93
89
|
/^\s*bat\b/,
|
|
94
90
|
/^\s*eza\b/,
|
|
95
91
|
]
|
|
96
92
|
|
|
93
|
+
// The shell can hide an arbitrary command inside any of these, so they are refused
|
|
94
|
+
// outright rather than parsed.
|
|
95
|
+
const SUBSTITUTION = /\$\(|`|<\(|>\(/
|
|
96
|
+
|
|
97
|
+
// Claude Code's separator set (code.claude.com/docs/en/permissions): every subcommand
|
|
98
|
+
// must qualify on its own, otherwise an allowlisted first token buys the rest of the line.
|
|
99
|
+
const SEPARATORS = /\|\||&&|\|&|[;|&\n]/
|
|
100
|
+
|
|
101
|
+
// find is allowlisted for traversal only; these actions run commands or delete.
|
|
102
|
+
const FIND_ACTIONS = /\s-(exec|execdir|ok|okdir|delete|fls|fprint|fprintf)\b/
|
|
103
|
+
|
|
104
|
+
function isSafeSegment(segment: string): boolean {
|
|
105
|
+
if (DESTRUCTIVE_PATTERNS.some((p) => p.test(segment))) return false
|
|
106
|
+
if (!SAFE_PATTERNS.some((p) => p.test(segment))) return false
|
|
107
|
+
return !(/^\s*find\b/.test(segment) && FIND_ACTIONS.test(segment))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether plan mode should let this bash command run.
|
|
112
|
+
*
|
|
113
|
+
* Model steering, not a sandbox: an allowlisted interpreter can still read and write
|
|
114
|
+
* whatever the user can, so this narrows the blast radius of a wrong turn rather than
|
|
115
|
+
* containing a determined one. Only OS-level isolation would be a boundary.
|
|
116
|
+
*/
|
|
97
117
|
export function isSafeCommand(command: string): boolean {
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
118
|
+
if (SUBSTITUTION.test(command)) return false
|
|
119
|
+
const segments = command
|
|
120
|
+
.split(SEPARATORS)
|
|
121
|
+
.map((s) => s.trim())
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
return segments.length > 0 && segments.every(isSafeSegment)
|
|
101
124
|
}
|
|
102
125
|
|
|
103
126
|
export interface TodoItem {
|
|
@@ -123,16 +146,53 @@ export function cleanStepText(text: string): string {
|
|
|
123
146
|
return cleaned
|
|
124
147
|
}
|
|
125
148
|
|
|
149
|
+
// Horizontal whitespace before the newline: \s would include \n itself and overlap
|
|
150
|
+
// the following \n, which is what backtracks super-linearly.
|
|
151
|
+
const PLAN_HEADER = /\*{0,2}Plan:\*{0,2}[^\S\n]*\n/i
|
|
152
|
+
|
|
153
|
+
const isBlank = (ch: string | undefined): boolean => ch !== undefined && ch !== '\n' && ch.trim() === ''
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Text of a `1. step` / `2) step` line, stopping at an inline `*`, or undefined when
|
|
157
|
+
* the line is not a numbered step. Scanned rather than matched: the equivalent
|
|
158
|
+
* pattern needs adjacent quantifiers over overlapping classes, which backtracks
|
|
159
|
+
* super-linearly on a long line that turns out not to be a step.
|
|
160
|
+
*/
|
|
161
|
+
function numberedStepText(line: string): string | undefined {
|
|
162
|
+
let i = 0
|
|
163
|
+
while (isBlank(line[i])) i++
|
|
164
|
+
|
|
165
|
+
const digitsStart = i
|
|
166
|
+
while (line[i] >= '0' && line[i] <= '9') i++
|
|
167
|
+
if (i === digitsStart) return undefined
|
|
168
|
+
|
|
169
|
+
if (line[i] !== '.' && line[i] !== ')') return undefined
|
|
170
|
+
i++
|
|
171
|
+
|
|
172
|
+
const spaceStart = i
|
|
173
|
+
while (isBlank(line[i])) i++
|
|
174
|
+
if (i === spaceStart) return undefined // the marker must be followed by whitespace
|
|
175
|
+
|
|
176
|
+
for (let stars = 0; stars < 2 && line[i] === '*'; stars++) i++
|
|
177
|
+
const first = line[i]
|
|
178
|
+
if (first === undefined || first === '*' || isBlank(first)) return undefined
|
|
179
|
+
|
|
180
|
+
const rest = line.slice(i)
|
|
181
|
+
const star = rest.indexOf('*')
|
|
182
|
+
return star === -1 ? rest : rest.slice(0, star)
|
|
183
|
+
}
|
|
184
|
+
|
|
126
185
|
export function extractTodoItems(message: string): TodoItem[] {
|
|
127
186
|
const items: TodoItem[] = []
|
|
128
|
-
const headerMatch =
|
|
187
|
+
const headerMatch = PLAN_HEADER.exec(message)
|
|
129
188
|
if (!headerMatch) return items
|
|
130
189
|
|
|
131
190
|
const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length)
|
|
132
|
-
const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm
|
|
133
191
|
|
|
134
|
-
for (const
|
|
135
|
-
const
|
|
192
|
+
for (const line of planSection.split('\n')) {
|
|
193
|
+
const captured = numberedStepText(line)
|
|
194
|
+
if (captured === undefined) continue
|
|
195
|
+
const text = captured
|
|
136
196
|
.trim()
|
|
137
197
|
.replace(/\*{1,2}$/, '')
|
|
138
198
|
.trim()
|
|
@@ -170,6 +230,6 @@ export function markCompletedSteps(text: string, items: TodoItem[]): number {
|
|
|
170
230
|
* the tool input is already known to be the plan itself.
|
|
171
231
|
*/
|
|
172
232
|
export function planToTodos(plan: string): TodoItem[] {
|
|
173
|
-
const withHeader =
|
|
233
|
+
const withHeader = PLAN_HEADER.test(plan) ? plan : `Plan:\n${plan}`
|
|
174
234
|
return extractTodoItems(withHeader)
|
|
175
235
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Trust Extension
|
|
3
|
+
*
|
|
4
|
+
* pi decides whether to prompt for trust from `hasTrustRequiringProjectResources`,
|
|
5
|
+
* which looks only at entries under `cwd/.pi` and at `.agents/skills`. A repository
|
|
6
|
+
* that ships only Claude Code shaped config, `.claude/` plus `.mcp.json`, matches
|
|
7
|
+
* none of them, so pi trusts it without asking.
|
|
8
|
+
*
|
|
9
|
+
* That is exactly the shape pi-code exists to load, and the other extensions gate
|
|
10
|
+
* their project input on `ctx.isProjectTrusted()`. Without this handler that flag is
|
|
11
|
+
* true for a freshly cloned repo nobody was asked about, and a project `.mcp.json`
|
|
12
|
+
* server command, project hooks and project agents all run.
|
|
13
|
+
*
|
|
14
|
+
* Only user/global and CLI extensions receive `project_trust`, so this works when
|
|
15
|
+
* pi-code is installed with `pi install npm:pi-code`. A project-local install
|
|
16
|
+
* (`pi install -l`) is not loaded until trust is already resolved.
|
|
17
|
+
*
|
|
18
|
+
* Docs: node_modules/@earendil-works/pi-coding-agent/docs/extensions.md (project_trust)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as fs from 'node:fs'
|
|
22
|
+
import * as path from 'node:path'
|
|
23
|
+
import { type ExtensionAPI, getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from '@earendil-works/pi-coding-agent'
|
|
24
|
+
|
|
25
|
+
/** Project files pi-code acts on that pi's own trust check does not look for. */
|
|
26
|
+
const CLAUDE_SHAPED = [path.join('.claude', 'settings.json'), path.join('.claude', 'settings.local.json'), path.join('.claude', 'agents'), path.join('.claude', 'hooks'), path.join('.claude', 'output-styles'), '.mcp.json', path.join('.pi', 'mcp.json'), path.join('.pi', 'agents')]
|
|
27
|
+
|
|
28
|
+
export function hasClaudeShapedConfig(cwd: string): boolean {
|
|
29
|
+
return CLAUDE_SHAPED.some((entry) => fs.existsSync(path.join(cwd, entry)))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type TrustDecision = { trusted: 'yes' | 'no' | 'undecided'; remember?: boolean }
|
|
33
|
+
|
|
34
|
+
interface TrustDeps {
|
|
35
|
+
hasClaudeShaped: (cwd: string) => boolean
|
|
36
|
+
piWouldAsk: (cwd: string) => boolean
|
|
37
|
+
savedDecision: (cwd: string) => boolean | null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const defaultDeps: TrustDeps = {
|
|
41
|
+
hasClaudeShaped: hasClaudeShapedConfig,
|
|
42
|
+
piWouldAsk: hasTrustRequiringProjectResources,
|
|
43
|
+
savedDecision: (cwd) => new ProjectTrustStore(getAgentDir()).get(cwd),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether to take over the trust decision for this project.
|
|
48
|
+
*
|
|
49
|
+
* Returns `undecided` wherever pi already resolves things correctly, so a remembered
|
|
50
|
+
* decision is never overridden and a headless run keeps whatever `defaultProjectTrust`
|
|
51
|
+
* says rather than being forced closed.
|
|
52
|
+
*/
|
|
53
|
+
export async function decideTrust(cwd: string, hasUI: boolean, confirm: (title: string, body: string) => Promise<boolean>, deps: TrustDeps = defaultDeps): Promise<TrustDecision> {
|
|
54
|
+
if (!deps.hasClaudeShaped(cwd)) return { trusted: 'undecided' }
|
|
55
|
+
if (deps.piWouldAsk(cwd)) return { trusted: 'undecided' } // pi prompts on its own
|
|
56
|
+
if (deps.savedDecision(cwd) !== null) return { trusted: 'undecided' } // apply the stored answer
|
|
57
|
+
if (!hasUI) return { trusted: 'undecided' } // cannot ask; leave pi's default in charge
|
|
58
|
+
|
|
59
|
+
const approved = await confirm('Trust this project?', `${cwd}\n\nIt ships Claude Code configuration that pi-code loads: MCP servers, hooks and agents can run commands from this repository.`)
|
|
60
|
+
return { trusted: approved ? 'yes' : 'no', remember: true }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default function projectTrustExtension(pi: ExtensionAPI) {
|
|
64
|
+
pi.on('project_trust', async (event, ctx) => decideTrust(event.cwd, ctx.hasUI, (title, body) => ctx.ui.confirm(title, body)))
|
|
65
|
+
}
|
package/extensions/question.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Escape in editor returns to options, Escape in options cancels
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
7
|
+
import type { ExtensionAPI, Theme } from '@earendil-works/pi-coding-agent'
|
|
8
8
|
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from '@earendil-works/pi-tui'
|
|
9
9
|
import { Type } from 'typebox'
|
|
10
10
|
|
|
@@ -33,6 +33,60 @@ const QuestionParams = Type.Object({
|
|
|
33
33
|
options: Type.Array(OptionSchema, { description: 'Options for the user to choose from' }),
|
|
34
34
|
})
|
|
35
35
|
|
|
36
|
+
function optionLine(opt: DisplayOption, index: number, selected: boolean, editMode: boolean, theme: Theme): string {
|
|
37
|
+
const label = `${index + 1}. ${opt.label}`
|
|
38
|
+
const prefix = selected ? theme.fg('accent', '> ') : ' '
|
|
39
|
+
if (opt.isOther === true && editMode) {
|
|
40
|
+
return prefix + theme.fg('accent', `${label} ✎`)
|
|
41
|
+
}
|
|
42
|
+
if (selected) {
|
|
43
|
+
return prefix + theme.fg('accent', label)
|
|
44
|
+
}
|
|
45
|
+
return ` ${theme.fg('text', label)}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface QuestionView {
|
|
49
|
+
width: number
|
|
50
|
+
question: string
|
|
51
|
+
options: DisplayOption[]
|
|
52
|
+
optionIndex: number
|
|
53
|
+
editMode: boolean
|
|
54
|
+
editor: Editor
|
|
55
|
+
theme: Theme
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildQuestionLines(view: QuestionView): string[] {
|
|
59
|
+
const { width, question, options, optionIndex, editMode, editor, theme } = view
|
|
60
|
+
const lines: string[] = []
|
|
61
|
+
const add = (s: string) => lines.push(truncateToWidth(s, width))
|
|
62
|
+
|
|
63
|
+
add(theme.fg('accent', '─'.repeat(width)))
|
|
64
|
+
add(theme.fg('text', ` ${question}`))
|
|
65
|
+
lines.push('')
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < options.length; i++) {
|
|
68
|
+
const opt = options[i]
|
|
69
|
+
add(optionLine(opt, i, i === optionIndex, editMode, theme))
|
|
70
|
+
if (opt.description) {
|
|
71
|
+
add(` ${theme.fg('muted', opt.description)}`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (editMode) {
|
|
76
|
+
lines.push('')
|
|
77
|
+
add(theme.fg('muted', ' Your answer:'))
|
|
78
|
+
for (const line of editor.render(width - 2)) {
|
|
79
|
+
add(` ${line}`)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
lines.push('')
|
|
84
|
+
add(theme.fg('dim', editMode ? ' Enter to submit • Esc to go back' : ' ↑↓ navigate • Enter to select • Esc to cancel'))
|
|
85
|
+
add(theme.fg('accent', '─'.repeat(width)))
|
|
86
|
+
|
|
87
|
+
return lines
|
|
88
|
+
}
|
|
89
|
+
|
|
36
90
|
export default function question(pi: ExtensionAPI) {
|
|
37
91
|
pi.registerTool({
|
|
38
92
|
name: 'question',
|
|
@@ -65,6 +119,7 @@ export default function question(pi: ExtensionAPI) {
|
|
|
65
119
|
let optionIndex = 0
|
|
66
120
|
let editMode = false
|
|
67
121
|
let cachedLines: string[] | undefined
|
|
122
|
+
let cachedWidth: number | undefined
|
|
68
123
|
|
|
69
124
|
const editorTheme: EditorTheme = {
|
|
70
125
|
borderColor: (s) => theme.fg('accent', s),
|
|
@@ -135,58 +190,16 @@ export default function question(pi: ExtensionAPI) {
|
|
|
135
190
|
}
|
|
136
191
|
|
|
137
192
|
function render(width: number): string[] {
|
|
138
|
-
if (cachedLines) return cachedLines
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
add(theme.fg('accent', '─'.repeat(width)))
|
|
144
|
-
add(theme.fg('text', ` ${params.question}`))
|
|
145
|
-
lines.push('')
|
|
146
|
-
|
|
147
|
-
for (let i = 0; i < allOptions.length; i++) {
|
|
148
|
-
const opt = allOptions[i]
|
|
149
|
-
const selected = i === optionIndex
|
|
150
|
-
const isOther = opt.isOther === true
|
|
151
|
-
const prefix = selected ? theme.fg('accent', '> ') : ' '
|
|
152
|
-
|
|
153
|
-
if (isOther && editMode) {
|
|
154
|
-
add(prefix + theme.fg('accent', `${i + 1}. ${opt.label} ✎`))
|
|
155
|
-
} else if (selected) {
|
|
156
|
-
add(prefix + theme.fg('accent', `${i + 1}. ${opt.label}`))
|
|
157
|
-
} else {
|
|
158
|
-
add(` ${theme.fg('text', `${i + 1}. ${opt.label}`)}`)
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Show description if present
|
|
162
|
-
if (opt.description) {
|
|
163
|
-
add(` ${theme.fg('muted', opt.description)}`)
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (editMode) {
|
|
168
|
-
lines.push('')
|
|
169
|
-
add(theme.fg('muted', ' Your answer:'))
|
|
170
|
-
for (const line of editor.render(width - 2)) {
|
|
171
|
-
add(` ${line}`)
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
lines.push('')
|
|
176
|
-
if (editMode) {
|
|
177
|
-
add(theme.fg('dim', ' Enter to submit • Esc to go back'))
|
|
178
|
-
} else {
|
|
179
|
-
add(theme.fg('dim', ' ↑↓ navigate • Enter to select • Esc to cancel'))
|
|
180
|
-
}
|
|
181
|
-
add(theme.fg('accent', '─'.repeat(width)))
|
|
182
|
-
|
|
183
|
-
cachedLines = lines
|
|
184
|
-
return lines
|
|
193
|
+
if (cachedLines && cachedWidth === width) return cachedLines
|
|
194
|
+
cachedWidth = width
|
|
195
|
+
cachedLines = buildQuestionLines({ width, question: params.question, options: allOptions, optionIndex, editMode, editor, theme })
|
|
196
|
+
return cachedLines
|
|
185
197
|
}
|
|
186
198
|
|
|
187
199
|
return {
|
|
188
200
|
render,
|
|
189
201
|
invalidate: () => {
|
|
202
|
+
cachedWidth = undefined
|
|
190
203
|
cachedLines = undefined
|
|
191
204
|
},
|
|
192
205
|
handleInput,
|
|
@@ -231,7 +244,8 @@ export default function question(pi: ExtensionAPI) {
|
|
|
231
244
|
if (opts.length) {
|
|
232
245
|
const labels = opts.map((o: OptionWithDesc) => o.label)
|
|
233
246
|
const numbered = [...labels, 'Type something.'].map((o, i) => `${i + 1}. ${o}`)
|
|
234
|
-
|
|
247
|
+
const optionsLine = ` Options: ${numbered.join(', ')}`
|
|
248
|
+
text += `\n${theme.fg('dim', optionsLine)}`
|
|
235
249
|
}
|
|
236
250
|
return new Text(text, 0, 0)
|
|
237
251
|
},
|
|
@@ -111,6 +111,17 @@ function findNearestDir(cwd: string, relative: string): string | null {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
function buildAgentMap(userAgents: AgentConfig[], projectAgents: AgentConfig[], scope: AgentScope): Map<string, AgentConfig> {
|
|
115
|
+
const agentMap = new Map<string, AgentConfig>()
|
|
116
|
+
const register = (agents: AgentConfig[]): void => {
|
|
117
|
+
for (const agent of agents) agentMap.set(agent.name, agent)
|
|
118
|
+
}
|
|
119
|
+
// user agents first so project agents win on name conflicts
|
|
120
|
+
if (scope !== 'project') register(userAgents)
|
|
121
|
+
if (scope !== 'user') register(projectAgents)
|
|
122
|
+
return agentMap
|
|
123
|
+
}
|
|
124
|
+
|
|
114
125
|
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
|
115
126
|
const userDir = path.join(getAgentDir(), 'agents')
|
|
116
127
|
const claudeUserDir = path.join(os.homedir(), '.claude', 'agents')
|
|
@@ -122,17 +133,7 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
|
|
|
122
133
|
// project .claude/agents loads first so project .pi/agents wins on name conflicts
|
|
123
134
|
const projectAgents = scope === 'user' ? [] : [...(projectClaudeDir ? loadAgentsFromDir(projectClaudeDir, 'project') : []), ...(projectPiDir ? loadAgentsFromDir(projectPiDir, 'project') : [])]
|
|
124
135
|
|
|
125
|
-
const agentMap =
|
|
126
|
-
|
|
127
|
-
if (scope === 'both') {
|
|
128
|
-
for (const agent of userAgents) agentMap.set(agent.name, agent)
|
|
129
|
-
for (const agent of projectAgents) agentMap.set(agent.name, agent)
|
|
130
|
-
} else if (scope === 'user') {
|
|
131
|
-
for (const agent of userAgents) agentMap.set(agent.name, agent)
|
|
132
|
-
} else {
|
|
133
|
-
for (const agent of projectAgents) agentMap.set(agent.name, agent)
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
+
const agentMap = buildAgentMap(userAgents, projectAgents, scope)
|
|
136
137
|
return { agents: Array.from(agentMap.values()), projectAgentsDir: projectPiDir }
|
|
137
138
|
}
|
|
138
139
|
|