pi-code 1.0.58 → 1.0.59
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.
|
@@ -67,9 +67,10 @@ import { createHash } from 'node:crypto'
|
|
|
67
67
|
import * as fs from 'node:fs'
|
|
68
68
|
import * as os from 'node:os'
|
|
69
69
|
import * as path from 'node:path'
|
|
70
|
-
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
71
|
-
|
|
70
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
72
71
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
72
|
+
import { AGENTS_FILE_NAMES } from './internal/context-files.js'
|
|
73
|
+
import { externalImportDecision, externalImportKey, rememberExternalImportDecision } from './internal/external-imports.js'
|
|
73
74
|
import { type InstructionLoadEvent, memoryTypeForPath, publishInstructionLoad } from './internal/instruction-events.js'
|
|
74
75
|
import { managedSettingsPath, readManagedSettings } from './internal/managed-settings.js'
|
|
75
76
|
import { capForContext, sliceBytes } from './internal/output-guard.js'
|
|
@@ -107,8 +108,13 @@ export function expandHome(target: string, home: string): string {
|
|
|
107
108
|
return target
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
/** Stands for "no boundary" in an allowed-roots list: the grant a project gets when
|
|
112
|
+
* its external imports are approved, where a file may import from anywhere as Claude's
|
|
113
|
+
* dialog allows. A real path never equals it, and realRoots never produces it. */
|
|
114
|
+
const ANY_ROOT = '*'
|
|
115
|
+
|
|
110
116
|
function isUnder(target: string, roots: string[]): boolean {
|
|
111
|
-
return roots.some((root) => target === root || target.startsWith(root + path.sep))
|
|
117
|
+
return roots.some((root) => root === ANY_ROOT || target === root || target.startsWith(root + path.sep))
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
/** Realpath the roots that exist; used both to seed and to bound the import search. */
|
|
@@ -271,11 +277,16 @@ export function collectImports(content: string, fromDir: string, home: string, a
|
|
|
271
277
|
* project's transcripts, so granting those roots to a cloned repo's `CLAUDE.md`
|
|
272
278
|
* would let it read them into the system prompt.
|
|
273
279
|
*/
|
|
274
|
-
export function rootsForImporter(importer: string, home: string, cwd: string): string[] {
|
|
280
|
+
export function rootsForImporter(importer: string, home: string, cwd: string, externalApproved = false): string[] {
|
|
275
281
|
const userRoots = realRoots([claudeConfigDir(home), path.join(home, '.pi')])
|
|
276
282
|
const [real] = realRoots([importer])
|
|
277
283
|
const fromUserConfig = real !== undefined && isUnder(real, userRoots)
|
|
278
284
|
if (fromUserConfig) return realRoots([cwd, ...userRoots])
|
|
285
|
+
// The project was asked about its external imports and allowed them, so a project
|
|
286
|
+
// file may reach outside, as Claude's dialog grants. The widening is deliberately
|
|
287
|
+
// only for project files: a user-scope file's roots are its own config, and an
|
|
288
|
+
// approval given to a repository must not extend them.
|
|
289
|
+
if (externalApproved) return [ANY_ROOT]
|
|
279
290
|
// A non-config file is bounded at the repository root: that covers an ancestor
|
|
280
291
|
// context file (a repo-root CLAUDE.md or CLAUDE.local.md in a subdirectory
|
|
281
292
|
// session, where cwd alone silently dropped its relative imports) without
|
|
@@ -498,24 +509,57 @@ function additionalDirExtras(addDirs: string[], seenSet: Set<string>, excluded:
|
|
|
498
509
|
return extras
|
|
499
510
|
}
|
|
500
511
|
|
|
512
|
+
/** The parts of one launch-time expansion that every file shares: where it runs, what
|
|
513
|
+
* has already been read, the budget they all draw on, and whether this project's
|
|
514
|
+
* external imports were approved. */
|
|
515
|
+
interface ExpansionContext {
|
|
516
|
+
home: string
|
|
517
|
+
cwd: string
|
|
518
|
+
seen: Set<string>
|
|
519
|
+
excluded: (absPath: string) => boolean
|
|
520
|
+
budget: ImportBudget
|
|
521
|
+
externalApproved: boolean
|
|
522
|
+
}
|
|
523
|
+
|
|
501
524
|
/** Resolve every context and additional-dir file's @imports through the one shared
|
|
502
525
|
* budget, each with roots scoped to the importing file so a project file never
|
|
503
|
-
* reaches user config. */
|
|
504
|
-
function expandImports(contextFiles: Array<{ path: string; content: string }>, extras: Array<{ path: string; content: string; dir: string }>,
|
|
526
|
+
* reaches user config unless the project's external imports were approved. */
|
|
527
|
+
function expandImports(contextFiles: Array<{ path: string; content: string }>, extras: Array<{ path: string; content: string; dir: string }>, run: ExpansionContext): ImportedFile[] {
|
|
505
528
|
const imported: ImportedFile[] = []
|
|
529
|
+
const options = { budget: run.budget, isExcluded: run.excluded }
|
|
506
530
|
for (const file of contextFiles) {
|
|
507
|
-
const allowedRoots = rootsForImporter(file.path, home, cwd)
|
|
508
|
-
imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots,
|
|
531
|
+
const allowedRoots = rootsForImporter(file.path, run.home, run.cwd, run.externalApproved)
|
|
532
|
+
imported.push(...collectImports(file.content, path.dirname(file.path), run.home, allowedRoots, run.seen, { ...options, importer: file.path }))
|
|
509
533
|
}
|
|
510
534
|
for (const extra of extras) {
|
|
511
535
|
// The additional dir itself is an allowed root, so its files' relative imports
|
|
512
536
|
// resolve even from .claude/rules two levels down.
|
|
513
|
-
const allowedRoots = [...realRoots([extra.dir]), ...rootsForImporter(extra.path, home, cwd)]
|
|
514
|
-
imported.push(...collectImports(extra.content, path.dirname(extra.path), home, allowedRoots,
|
|
537
|
+
const allowedRoots = [...realRoots([extra.dir]), ...rootsForImporter(extra.path, run.home, run.cwd)]
|
|
538
|
+
imported.push(...collectImports(extra.content, path.dirname(extra.path), run.home, allowedRoots, run.seen, { ...options, importer: extra.path }))
|
|
515
539
|
}
|
|
516
540
|
return imported
|
|
517
541
|
}
|
|
518
542
|
|
|
543
|
+
/** The external-import dialog's title. Exported so a test can tell it apart from the
|
|
544
|
+
* project-trust dialog by identity rather than by matching a prefix that a retitle
|
|
545
|
+
* would silently break. */
|
|
546
|
+
export const EXTERNAL_IMPORT_PROMPT_TITLE = 'Load imports from outside this project?'
|
|
547
|
+
|
|
548
|
+
/** Ask about the imports the expansion just refused for leaving the project.
|
|
549
|
+
*
|
|
550
|
+
* The list is the refusals the enforcing path produced, not a second enumeration of
|
|
551
|
+
* what it might refuse: same files, same depth, same resolution, same exclusions. That
|
|
552
|
+
* is the only way the dialog can promise it names everything the approval lets in.
|
|
553
|
+
*/
|
|
554
|
+
async function askExternalImports(ctx: ExtensionContext, root: string, refused: ReadonlySet<string>): Promise<boolean> {
|
|
555
|
+
const listed = [...refused]
|
|
556
|
+
.sort((a, b) => a.localeCompare(b, 'en'))
|
|
557
|
+
.map((file) => ` ${file}`)
|
|
558
|
+
.join('\n')
|
|
559
|
+
const body = `${root}\n\nIts context files import these files from outside the project:\n\n${listed}\n\nThey will be read into every session's context. Only allow this for repositories you trust.`
|
|
560
|
+
return await ctx.ui.confirm(EXTERNAL_IMPORT_PROMPT_TITLE, body)
|
|
561
|
+
}
|
|
562
|
+
|
|
519
563
|
/** Every context file that could load on demand for a touched directory, shallowest
|
|
520
564
|
* first, so the deepest instructions are read last as they are at launch. */
|
|
521
565
|
function* nestedCandidates(touchedDir: string, realCwd: string): Generator<{ file: string; dir: string; name: string }> {
|
|
@@ -556,14 +600,17 @@ function nestedContextBlock(file: string, dir: string, load: NestedLoadContext):
|
|
|
556
600
|
// seed the seen set, so a nested file importing the root CLAUDE.md does not pay for
|
|
557
601
|
// a body already in the system prompt.
|
|
558
602
|
const seen = new Set([...load.launchLoaded, real])
|
|
603
|
+
const budget = createImportBudget()
|
|
559
604
|
const imports = collectImports(content, dir, load.home, rootsForImporter(real, load.home, load.cwd), seen, {
|
|
560
605
|
importer: real,
|
|
561
606
|
isExcluded: (absPath) => isExcludedPath(absPath, load.excludeGlobs, load.home),
|
|
562
|
-
budget
|
|
607
|
+
budget,
|
|
563
608
|
})
|
|
564
609
|
return {
|
|
565
610
|
read: true,
|
|
566
|
-
|
|
611
|
+
// The refusal notice rides along, so an import this file names and does not get is
|
|
612
|
+
// as visible here as it is at launch.
|
|
613
|
+
text: [instructionsBlock(file, body), ...imports.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`)].join('\n\n') + refusedImportsAddition(budget.refused),
|
|
567
614
|
events: [
|
|
568
615
|
{ file_path: file, memory_type: memoryTypeForPath(file, load.home, load.projectRoot), load_reason: 'nested_traversal', trigger_file_path: load.touched },
|
|
569
616
|
...imports.map((entry) => ({
|
|
@@ -599,11 +646,6 @@ function nestedContextDirs(from: string, cwd: string): string[] {
|
|
|
599
646
|
return current === cwd ? dirs : []
|
|
600
647
|
}
|
|
601
648
|
|
|
602
|
-
/** The context-file names pi prefers over CLAUDE.md in the same directory. Mirrors
|
|
603
|
-
* pi's own lookup order (init.ts CONTEXT_FILE_CANDIDATES); the sibling search below
|
|
604
|
-
* keys off what pi actually loaded, so this is only used to recognize those files. */
|
|
605
|
-
const AGENTS_FILE_NAMES = new Set(['AGENTS.override.md', 'AGENTS.md', 'AGENTS.MD'])
|
|
606
|
-
|
|
607
649
|
/** The blocks and instruction loads for one touched directory. `loaded` is the
|
|
608
650
|
* session's set of already-attached files and is updated in place, so a file that
|
|
609
651
|
* exists is attached once and a missing one can still appear later. */
|
|
@@ -766,6 +808,7 @@ function buildImportMemoKey(input: {
|
|
|
766
808
|
cwd: string
|
|
767
809
|
home: string
|
|
768
810
|
projectApproved: boolean
|
|
811
|
+
externalApproved: boolean
|
|
769
812
|
addDirsRaw: string
|
|
770
813
|
excludeGlobs: string[]
|
|
771
814
|
native: Array<{ path: string; content: string }>
|
|
@@ -776,7 +819,7 @@ function buildImportMemoKey(input: {
|
|
|
776
819
|
contextFiles: Array<{ path: string; content: string }>
|
|
777
820
|
}): string {
|
|
778
821
|
const keyHash = createHash('sha256')
|
|
779
|
-
keyHash.update(`${input.cwd}\0${input.home}\0${input.projectApproved}\0${input.addDirsRaw}\0${input.excludeGlobs.join(',')}\0`)
|
|
822
|
+
keyHash.update(`${input.cwd}\0${input.home}\0${input.projectApproved}\0${input.externalApproved}\0${input.addDirsRaw}\0${input.excludeGlobs.join(',')}\0`)
|
|
780
823
|
for (const file of [...input.native, ...input.localContexts]) keyHash.update(`${file.path}\0`)
|
|
781
824
|
if (input.userContext !== undefined) keyHash.update(`${input.userContext.path}\0`)
|
|
782
825
|
if (input.projectDotClaude !== undefined) keyHash.update(`${input.projectDotClaude.path}\0`)
|
|
@@ -850,12 +893,8 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
850
893
|
// set) is rebuilt for next turn.
|
|
851
894
|
const resolveImports = (
|
|
852
895
|
memoKey: string,
|
|
853
|
-
native: Array<{ path: string; content: string }
|
|
854
|
-
|
|
855
|
-
siblings: Array<{ path: string; content: string }>,
|
|
856
|
-
home: string,
|
|
857
|
-
cwd: string,
|
|
858
|
-
excluded: (absPath: string) => boolean,
|
|
896
|
+
files: { native: Array<{ path: string; content: string }>; context: Array<{ path: string; content: string }>; siblings: Array<{ path: string; content: string }> },
|
|
897
|
+
run: { home: string; cwd: string; excluded: (absPath: string) => boolean; externalApproved: boolean },
|
|
859
898
|
): { extras: Array<{ path: string; content: string; dir: string }>; budget: ImportBudget; imported: ImportedFile[] } => {
|
|
860
899
|
if (importMemo?.key === memoKey && memoIsFresh(importMemo)) {
|
|
861
900
|
const { extras, budget, imported } = importMemo
|
|
@@ -865,7 +904,7 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
865
904
|
// files are never re-imported and an excluded file cannot return as an import.
|
|
866
905
|
// The user, project-.claude and managed-file additions join the seed too, so a
|
|
867
906
|
// context file's @import cannot pull any of them in a second time.
|
|
868
|
-
const ownPaths = [...native, ...localContexts, ...siblings].map((file) => file.path)
|
|
907
|
+
const ownPaths = [...files.native, ...localContexts, ...files.siblings].map((file) => file.path)
|
|
869
908
|
if (userContext !== undefined) ownPaths.push(userContext.path)
|
|
870
909
|
if (projectDotClaude !== undefined) ownPaths.push(projectDotClaude.path)
|
|
871
910
|
ownPaths.push(managedClaudeMdPath())
|
|
@@ -874,14 +913,14 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
874
913
|
// Claude's --add-dir memory loading, env-gated. The files join the seen set
|
|
875
914
|
// before import expansion so an @import cannot pull one in twice, and they get
|
|
876
915
|
// the same exclude and comment-strip treatment as native context files.
|
|
877
|
-
const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), home, cwd) : []
|
|
878
|
-
const extras = additionalDirExtras(addDirs, seenSet, excluded, projectApproved)
|
|
916
|
+
const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), run.home, run.cwd) : []
|
|
917
|
+
const extras = additionalDirExtras(addDirs, seenSet, run.excluded, projectApproved)
|
|
879
918
|
|
|
880
919
|
// One budget for the whole run, so N context files cannot each spend a full one.
|
|
881
920
|
// Exclusion applies inside the recursion: an excluded @import is skipped before
|
|
882
921
|
// it is read, so its transitive imports never load and it spends no budget.
|
|
883
922
|
const budget = createImportBudget()
|
|
884
|
-
const imported = expandImports(
|
|
923
|
+
const imported = expandImports(files.context, extras, { ...run, seen: seenSet, budget })
|
|
885
924
|
|
|
886
925
|
// Revalidation set: every file the expansion read, plus each add-dir itself
|
|
887
926
|
// (a directory's mtime moves when a memory file is added or removed there).
|
|
@@ -945,7 +984,27 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
945
984
|
gatedFilesApproved = isGatedFileApproved(ctx)
|
|
946
985
|
})
|
|
947
986
|
|
|
948
|
-
|
|
987
|
+
/** The launch-time expansion, asking about what it refuses for leaving the project
|
|
988
|
+
* when this project has not been asked yet.
|
|
989
|
+
*
|
|
990
|
+
* Claude asks once per project and remembers the answer either way. The list is what
|
|
991
|
+
* this very expansion refused, so it names exactly the files the answer governs, and
|
|
992
|
+
* on approval the expansion is simply redone with the wider roots. */
|
|
993
|
+
const expandAskingAboutExternals = async (
|
|
994
|
+
cwd: string,
|
|
995
|
+
ctx: ExtensionContext | undefined,
|
|
996
|
+
expandWith: (externalApproved: boolean) => { extras: Array<{ path: string; content: string; dir: string }>; budget: ImportBudget; imported: ImportedFile[] },
|
|
997
|
+
): Promise<{ extras: Array<{ path: string; content: string; dir: string }>; budget: ImportBudget; imported: ImportedFile[] }> => {
|
|
998
|
+
const key = externalImportKey(cwd)
|
|
999
|
+
const decided = externalImportDecision(key)
|
|
1000
|
+
const result = expandWith(decided === true)
|
|
1001
|
+
if (decided !== null || result.budget.refused.size === 0 || ctx?.hasUI !== true) return result
|
|
1002
|
+
const approved = await askExternalImports(ctx, key, result.budget.refused)
|
|
1003
|
+
rememberExternalImportDecision(key, approved)
|
|
1004
|
+
return approved ? expandWith(true) : result
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
pi.on('before_agent_start', async (event, ctx) => {
|
|
949
1008
|
const home = os.homedir()
|
|
950
1009
|
const cwd = event.systemPromptOptions?.cwd ?? process.cwd()
|
|
951
1010
|
const native: Array<{ path: string; content: string }> = event.systemPromptOptions?.contextFiles ?? []
|
|
@@ -1007,9 +1066,16 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
1007
1066
|
// Everything the expansion depends on, hashed: a turn whose inputs match the memo
|
|
1008
1067
|
// and whose recorded mtimes are unchanged reuses the previous expansion outright.
|
|
1009
1068
|
const addDirsRaw = additionalDirsClaudeMdEnabled() ? String(pi.getFlag?.('add-dir') ?? '') : ''
|
|
1010
|
-
const
|
|
1069
|
+
const expandWith = (externalApproved: boolean) => {
|
|
1070
|
+
const memoKey = buildImportMemoKey({ cwd, home, projectApproved, externalApproved, addDirsRaw, excludeGlobs, native, localContexts, userContext, projectDotClaude, managedFile, contextFiles })
|
|
1071
|
+
return resolveImports(memoKey, { native, context: contextFiles, siblings: keptSiblings }, { home, cwd, excluded, externalApproved })
|
|
1072
|
+
}
|
|
1011
1073
|
|
|
1012
|
-
|
|
1074
|
+
// Claude's external-import dialog. Asked from the refusals the expansion just
|
|
1075
|
+
// produced, so the files named are exactly the files the answer governs, and asked
|
|
1076
|
+
// here rather than at session start because only this event knows which context
|
|
1077
|
+
// files pi actually loaded. Once per project: the answer is remembered either way.
|
|
1078
|
+
const { extras, budget, imported } = await expandAskingAboutExternals(cwd, ctx, expandWith)
|
|
1013
1079
|
launchLoadedPaths = realRoots([...contextFiles.map((file) => file.path), ...imported.map((entry) => entry.path)])
|
|
1014
1080
|
|
|
1015
1081
|
// Project memory precedes local memory, so the ./.claude/CLAUDE.md block leads the
|
package/extensions/init.ts
CHANGED
|
@@ -16,13 +16,15 @@
|
|
|
16
16
|
|
|
17
17
|
import * as fs from 'node:fs'
|
|
18
18
|
import * as path from 'node:path'
|
|
19
|
+
|
|
20
|
+
import { CONTEXT_FILE_CANDIDATES } from './internal/context-files.js'
|
|
21
|
+
|
|
22
|
+
export { CONTEXT_FILE_CANDIDATES }
|
|
23
|
+
|
|
19
24
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
20
25
|
|
|
21
26
|
import { repoRoot } from './internal/project-root.js'
|
|
22
27
|
|
|
23
|
-
/** Context files pi recognizes, in its lookup order; the first hit wins. */
|
|
24
|
-
export const CONTEXT_FILE_CANDIDATES = ['AGENTS.override.md', 'AGENTS.md', 'AGENTS.MD', 'CLAUDE.md', 'CLAUDE.MD']
|
|
25
|
-
|
|
26
28
|
function statOf(target: string): fs.Stats | undefined {
|
|
27
29
|
try {
|
|
28
30
|
return fs.statSync(target)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context-file names pi looks for, in its own lookup order.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors pi's resource loader: it takes the first of these that exists in a
|
|
5
|
+
* directory and ignores the rest, so a repository holding both AGENTS.md and
|
|
6
|
+
* CLAUDE.md loads only the first. Anything reasoning about what pi loaded, or about
|
|
7
|
+
* what it passed over, has to use the same list in the same order.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** pi's per-directory candidates, first hit wins. */
|
|
11
|
+
export const CONTEXT_FILE_CANDIDATES = ['AGENTS.override.md', 'AGENTS.md', 'AGENTS.MD', 'CLAUDE.md', 'CLAUDE.MD']
|
|
12
|
+
|
|
13
|
+
/** The candidates pi prefers over CLAUDE.md in the same directory. */
|
|
14
|
+
export const AGENTS_FILE_NAMES: ReadonlySet<string> = new Set(['AGENTS.override.md', 'AGENTS.md', 'AGENTS.MD'])
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a project may load `@imports` that resolve outside it.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code asks once per project, listing the external files, and remembers the
|
|
5
|
+
* answer: "The first time Claude Code encounters external imports in a project, it
|
|
6
|
+
* shows an approval dialog listing the files. If you decline, the imports stay
|
|
7
|
+
* disabled and the dialog doesn't appear again."
|
|
8
|
+
*
|
|
9
|
+
* The answer lives beside pi's own trust store rather than in the repository, so a
|
|
10
|
+
* clone cannot ship its own approval. It is keyed on the checkout: the resolved git
|
|
11
|
+
* root, or the resolved working directory outside a repository.
|
|
12
|
+
*
|
|
13
|
+
* The key is the git root and not the project root because a repository must not be
|
|
14
|
+
* able to move its own key. The project root also stops at package.json, so declining
|
|
15
|
+
* at the top of a monorepo and starting the next session inside a package produced a
|
|
16
|
+
* different key and asked again, which is not a decision that was kept. A separate
|
|
17
|
+
* worktree of one repository is a separate checkout and is asked separately.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as fs from 'node:fs'
|
|
21
|
+
import * as path from 'node:path'
|
|
22
|
+
|
|
23
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
24
|
+
|
|
25
|
+
import { atomicWriteFile } from './atomic-write.js'
|
|
26
|
+
import { gitRoot } from './project-root.js'
|
|
27
|
+
import { isRecord } from './values.js'
|
|
28
|
+
|
|
29
|
+
/** The key for a working directory: its checkout, resolved, so the same checkout
|
|
30
|
+
* reached through a symlink is the same project. */
|
|
31
|
+
export function externalImportKey(cwd: string): string {
|
|
32
|
+
const root = gitRoot(cwd) ?? cwd
|
|
33
|
+
try {
|
|
34
|
+
return fs.realpathSync(root)
|
|
35
|
+
} catch {
|
|
36
|
+
return root
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The store file. A seam: the tests point it at a temp directory. */
|
|
41
|
+
export function externalImportStorePath(agentDir: string = getAgentDir()): string {
|
|
42
|
+
return path.join(agentDir, 'pi-code-external-imports.json')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readStore(storePath: string): Record<string, unknown> {
|
|
46
|
+
try {
|
|
47
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(storePath, 'utf-8'))
|
|
48
|
+
return isRecord(parsed) ? parsed : {}
|
|
49
|
+
} catch {
|
|
50
|
+
// No store yet, or one a hand-edit left unparseable: no decision recorded.
|
|
51
|
+
return {}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The recorded answer for `root`, or null when the project has never been asked.
|
|
56
|
+
* Only a boolean counts: anything else in the file reads as unasked, so a corrupt
|
|
57
|
+
* entry re-asks rather than silently allowing or silently refusing forever. */
|
|
58
|
+
export function externalImportDecision(root: string, storePath: string = externalImportStorePath()): boolean | null {
|
|
59
|
+
const value = readStore(storePath)[root]
|
|
60
|
+
return typeof value === 'boolean' ? value : null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Record the answer for `root`, keeping every other project's. */
|
|
64
|
+
export function rememberExternalImportDecision(root: string, allowed: boolean, storePath: string = externalImportStorePath()): void {
|
|
65
|
+
const store = readStore(storePath)
|
|
66
|
+
store[root] = allowed
|
|
67
|
+
try {
|
|
68
|
+
fs.mkdirSync(path.dirname(storePath), { recursive: true })
|
|
69
|
+
atomicWriteFile(storePath, `${JSON.stringify(store, null, 2)}\n`)
|
|
70
|
+
} catch {
|
|
71
|
+
// An unwritable agent directory costs the memory of the answer, not the session:
|
|
72
|
+
// the next start asks again, which is the safe direction.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -27,6 +27,24 @@ export function repoRoot(from: string): string | undefined {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** The git checkout at or above `from`, or undefined outside one.
|
|
31
|
+
*
|
|
32
|
+
* Narrower than repoRoot on purpose, and used where a key must be stable rather than
|
|
33
|
+
* merely near: repoRoot also stops at package.json, which every package of a monorepo
|
|
34
|
+
* ships, so a decision keyed on it changes the moment the session starts one directory
|
|
35
|
+
* deeper. `.git` cannot be committed into a repository, so it is not a marker the
|
|
36
|
+
* repository can add to move its own key.
|
|
37
|
+
*/
|
|
38
|
+
export function gitRoot(from: string): string | undefined {
|
|
39
|
+
let currentDir = from
|
|
40
|
+
while (true) {
|
|
41
|
+
if (fs.existsSync(path.join(currentDir, '.git'))) return currentDir
|
|
42
|
+
const parentDir = path.dirname(currentDir)
|
|
43
|
+
if (parentDir === currentDir) return undefined
|
|
44
|
+
currentDir = parentDir
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
30
48
|
function statOf(target: string): fs.Stats | null {
|
|
31
49
|
try {
|
|
32
50
|
return fs.statSync(target)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.59",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, subagents, and goals",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|