pi-scout 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/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and uses semantic versioning for releases.
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - TBD
10
+
11
+ ### Added
12
+
13
+ - Initial `pi-scout` package scaffold.
14
+ - `/scout` command for registering, listing, and removing reference repositories.
15
+ - `scout_add` and conditional `scout_rm` tools.
16
+ - Compact system prompt injection for registered local repository paths with stale-temp pruning.
17
+ - GitHub `owner/repo` shorthand for repository registration.
18
+ - `scout_add` tool schema minimized to a single `source` parameter.
19
+ - Repository registration now defaults to shallow clones with depth `1`.
20
+ - Unix-like systems now use `/tmp/pi-scout` for shorter registered paths.
21
+ - Package-level extension entry point so Pi displays the extension as `pi-scout` instead of `extensions`/`extension`.
22
+ - Optional temporary clone deletion when removing repository records.
@@ -0,0 +1,41 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
6
+
7
+ We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
8
+
9
+ ## Our Standards
10
+
11
+ Examples of behavior that contributes to a positive environment include:
12
+
13
+ - Demonstrating empathy and kindness toward other people
14
+ - Being respectful of differing opinions, viewpoints, and experiences
15
+ - Giving and gracefully accepting constructive feedback
16
+ - Accepting responsibility and apologizing to those affected by our mistakes
17
+ - Focusing on what is best not just for us as individuals, but for the overall community
18
+
19
+ Examples of unacceptable behavior include:
20
+
21
+ - The use of sexualized language or imagery, and sexual attention or advances
22
+ - Trolling, insulting or derogatory comments, and personal or political attacks
23
+ - Public or private harassment
24
+ - Publishing others' private information without explicit permission
25
+ - Other conduct which could reasonably be considered inappropriate in a professional setting
26
+
27
+ ## Enforcement Responsibilities
28
+
29
+ Project maintainers are responsible for clarifying and enforcing standards of acceptable behavior and may remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct.
30
+
31
+ ## Scope
32
+
33
+ This Code of Conduct applies within all project spaces and also applies when an individual is officially representing the project in public spaces.
34
+
35
+ ## Enforcement
36
+
37
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainers through GitHub. All complaints will be reviewed and investigated promptly and fairly.
38
+
39
+ ## Attribution
40
+
41
+ This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
@@ -0,0 +1,50 @@
1
+ # Contributing
2
+
3
+ Thanks for your interest in contributing to `pi-scout`.
4
+
5
+ ## Development setup
6
+
7
+ ```bash
8
+ npm install
9
+ npm run check
10
+ ```
11
+
12
+ This package is source-distributed: Pi loads the TypeScript extension files directly. There is no build step for runtime use.
13
+
14
+ ## Local testing
15
+
16
+ Install this checkout into a temporary Pi project:
17
+
18
+ ```bash
19
+ mkdir -p <test-project>
20
+ cd <test-project>
21
+ pi install -l /path/to/pi-mono/packages/pi-scout
22
+ pi
23
+ ```
24
+
25
+ For a one-off run without changing settings:
26
+
27
+ ```bash
28
+ pi -e /path/to/pi-mono/packages/pi-scout --print "list your tools"
29
+ ```
30
+
31
+ ## Pull request checklist
32
+
33
+ Before opening a pull request:
34
+
35
+ - Run `npm run check`.
36
+ - Run `npm audit --omit=dev`.
37
+ - Run `npm run pack:dry-run` and confirm the package contents are intentional.
38
+ - Update `README.md` if user-facing behavior changes.
39
+ - Update `CHANGELOG.md` for notable changes.
40
+ - Keep examples and paths generic; do not commit machine-specific paths, API keys, tokens, or provider config containing secrets.
41
+
42
+ ## Coding guidelines
43
+
44
+ - Keep `extensions/index.ts` focused on Pi extension wiring and move reusable implementation details into `src/`.
45
+ - When adding tools, update Typebox schemas, runtime validation, README parameter docs, and tests together.
46
+ - Treat config keys, environment variables, and command names as public interface; changes to defaults or precedence are breaking changes.
47
+
48
+ ## Code of conduct
49
+
50
+ This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jose Mocito
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # pi-scout
2
+
3
+ A source-distributed [Pi](https://pi.dev) package for registering local reference codebases for agent exploration.
4
+
5
+ > [!WARNING]
6
+ > Pi packages can execute arbitrary code through extensions. Review package source before installing any third-party Pi package.
7
+
8
+ ## Features
9
+
10
+ - `/scout` slash command with a simple TUI flow for registering, listing, and removing reference repositories.
11
+ - `scout_add` tool for cloning a Git repository into a local temporary cache.
12
+ - `scout_rm` tool for agent-driven removal once repositories exist.
13
+ - Compact per-turn system prompt guidance with registered repo names and local clone paths.
14
+ - Automatic pruning: if the OS cleans a temporary clone, Pi Scout removes that stale entry before adding prompt context.
15
+
16
+ Registered repositories are cloned under `/tmp/pi-scout/<name>-<id>` on Unix-like systems, or the OS temp directory on Windows. Set `PI_SCOUT_TMPDIR` to override the parent temp directory. Pi Scout uses shallow clones with depth `1` by default because it is for code exploration, not history exploration. Pi Scout keeps records in Pi's agent directory and reuses them across sessions while the cloned directories still exist.
17
+
18
+ ## Installation
19
+
20
+ Install from npm:
21
+
22
+ ```bash
23
+ pi install npm:pi-scout
24
+ ```
25
+
26
+ Install project-locally with Pi's `-l` flag:
27
+
28
+ ```bash
29
+ pi install -l npm:pi-scout
30
+ ```
31
+
32
+ During local development from this monorepo:
33
+
34
+ ```bash
35
+ pi install /path/to/pi-mono/packages/pi-scout
36
+ ```
37
+
38
+ For a one-off test run without installing:
39
+
40
+ ```bash
41
+ pi -e /path/to/pi-mono/packages/pi-scout --print "list your tools"
42
+ ```
43
+
44
+ This is an npm-compatible TypeScript Pi package. There is no runtime build step.
45
+
46
+ ## Quick usage
47
+
48
+ Open the Pi Scout menu:
49
+
50
+ ```text
51
+ /scout
52
+ ```
53
+
54
+ Register a repository directly with a Git URL/path or GitHub shorthand:
55
+
56
+ ```text
57
+ /scout https://github.com/owner/repo.git
58
+ /scout owner/repo
59
+ ```
60
+
61
+ Ask the agent to register one:
62
+
63
+ ```text
64
+ Register https://github.com/owner/repo.git with Pi Scout, then inspect how it implements feature flags.
65
+ ```
66
+
67
+ After a repository is registered, the agent sees its local path in the system prompt and can inspect it with local file tools.
68
+
69
+ ## Tools
70
+
71
+ | Tool | Purpose |
72
+ |---|---|
73
+ | `scout_add` | Clone and register a Git repository as a local reference codebase. Takes only `source`: Git URL, local path, or GitHub `owner/repo` shorthand. |
74
+ | `scout_rm` | Remove a repository from Pi Scout records, optionally deleting the temporary clone. Available to the model only while repos are registered. |
75
+
76
+ ## Notes
77
+
78
+ - Pi Scout uses local file access for exploration. It does not provide web search or remote content-fetching tools.
79
+ - Registering a Git URL still uses `git clone`, so Git may contact the configured remote.
80
+ - Registered repositories are intended as read-only references unless the user explicitly asks otherwise.
81
+ - The system prompt includes only registered repo names and local paths, not origin URLs or branch metadata.
82
+
83
+ ## Development
84
+
85
+ ```bash
86
+ npm install
87
+ npm run check
88
+ npm run pack:dry-run
89
+ ```
package/SECURITY.md ADDED
@@ -0,0 +1,26 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+
5
+ Security fixes are provided for the latest released version of `pi-scout`.
6
+
7
+ ## Reporting a vulnerability
8
+
9
+ Please do not open a public issue for suspected security vulnerabilities.
10
+
11
+ Report privately by contacting the repository maintainer through GitHub. Include:
12
+
13
+ - a description of the issue;
14
+ - steps to reproduce;
15
+ - affected versions or commits, if known;
16
+ - any suggested mitigation.
17
+
18
+ The maintainer will acknowledge reports as soon as practical and coordinate disclosure once a fix or mitigation is available.
19
+
20
+ ## Security model
21
+
22
+ `pi-scout` is a Pi package. Pi extensions execute with the same permissions as the local user running Pi. Users should review installed Pi packages and only install packages from sources they trust.
23
+
24
+ Do not commit API keys, tokens, credentials, local settings, or machine-specific paths.
25
+
26
+ `pi-scout` stores repository records under Pi's agent directory and clones registered repositories into the OS temporary directory. It does not provide web search or content-fetching tools, but registering a Git URL uses `git clone`, which may contact the configured remote. Registered repository paths are appended to the system prompt so the agent can inspect them with local file tools.
@@ -0,0 +1,147 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { buildScoutPrompt, formatRepo, loadPrunedState, registerRepo, removeRepo } from "../src/index.js";
4
+
5
+ const RegisterRepoParams = Type.Object({
6
+ source: Type.String({ description: "Git URL/path or owner/repo." }),
7
+ });
8
+
9
+ const RemoveRepoParams = Type.Object({
10
+ idOrName: Type.String({ description: "Repo id or name." }),
11
+ deleteClone: Type.Optional(Type.Boolean({ description: "Delete temp clone too." })),
12
+ });
13
+
14
+ export default function piScout(pi: ExtensionAPI) {
15
+ let scoutRmRegistered = false;
16
+
17
+ function setToolActive(name: string, active: boolean): void {
18
+ const activeTools = pi.getActiveTools();
19
+ const hasTool = activeTools.includes(name);
20
+ if (active && !hasTool) pi.setActiveTools([...activeTools, name]);
21
+ if (!active && hasTool) pi.setActiveTools(activeTools.filter((tool) => tool !== name));
22
+ }
23
+
24
+ async function syncScoutRmTool(): Promise<void> {
25
+ const hasRepos = (await loadPrunedState()).repos.length > 0;
26
+ if (hasRepos && !scoutRmRegistered) {
27
+ pi.registerTool({
28
+ name: "scout_rm",
29
+ label: "Scout Remove",
30
+ description: "Remove a Scout repo record.",
31
+ promptSnippet: "Remove Scout repo records.",
32
+ promptGuidelines: [
33
+ "Use scout_rm only when asked to unregister a Scout repo.",
34
+ ],
35
+ parameters: RemoveRepoParams,
36
+ async execute(_toolCallId, rawParams) {
37
+ const params = rawParams as { idOrName: string; deleteClone?: boolean };
38
+ const removed = await removeRepo(params.idOrName, { deleteClone: params.deleteClone });
39
+ await syncScoutRmTool();
40
+ const text = removed
41
+ ? `Removed Pi Scout repository from records:\n${formatRepo(removed)}\n\nLocal clone ${params.deleteClone ? "deleted" : "was not deleted"}.`
42
+ : `No Pi Scout repository matched "${params.idOrName}".`;
43
+ return { content: [{ type: "text", text }], details: { removed, deletedClone: Boolean(params.deleteClone && removed) } };
44
+ },
45
+ });
46
+ scoutRmRegistered = true;
47
+ }
48
+ if (scoutRmRegistered) setToolActive("scout_rm", hasRepos);
49
+ }
50
+
51
+ pi.on("session_start", async () => {
52
+ await syncScoutRmTool();
53
+ });
54
+
55
+ pi.registerCommand("scout", {
56
+ description: "Manage Scout reference repos",
57
+ handler: async (args, ctx) => {
58
+ await handleScoutCommand(pi, args, ctx, syncScoutRmTool);
59
+ },
60
+ });
61
+
62
+ pi.registerTool({
63
+ name: "scout_add",
64
+ label: "Scout Add",
65
+ description: "Clone/register a reference repo.",
66
+ promptSnippet: "Add Scout reference repos.",
67
+ promptGuidelines: [
68
+ "Use scout_add to register a Git repo for code exploration.",
69
+ ],
70
+ parameters: RegisterRepoParams,
71
+ async execute(_toolCallId, rawParams, signal) {
72
+ const params = rawParams as { source: string };
73
+ const repo = await registerRepo(pi, { source: params.source, signal });
74
+ await syncScoutRmTool();
75
+ return {
76
+ content: [{ type: "text", text: `Registered Pi Scout repository:\n${formatRepo(repo)}` }],
77
+ details: { repo },
78
+ };
79
+ },
80
+ });
81
+
82
+
83
+ pi.on("before_agent_start", async (event) => {
84
+ await syncScoutRmTool();
85
+ const state = await loadPrunedState();
86
+ const scoutPrompt = buildScoutPrompt(state.repos);
87
+ if (!scoutPrompt) return;
88
+ return { systemPrompt: `${event.systemPrompt}\n\n${scoutPrompt}` };
89
+ });
90
+ }
91
+
92
+ async function handleScoutCommand(
93
+ pi: ExtensionAPI,
94
+ args: string,
95
+ ctx: ExtensionCommandContext,
96
+ syncScoutRmTool: () => Promise<void>,
97
+ ): Promise<void> {
98
+ const trimmed = args.trim();
99
+ if (trimmed) {
100
+ const repo = await registerRepo(pi, { source: trimmed });
101
+ await syncScoutRmTool();
102
+ ctx.ui.notify(`Pi Scout registered ${repo.name}`, "info");
103
+ return;
104
+ }
105
+
106
+ const action = await ctx.ui.select("Pi Scout", [
107
+ "Register repository",
108
+ "List repositories",
109
+ "Remove repository",
110
+ ]);
111
+
112
+ if (action === "Register repository") {
113
+ const source = await ctx.ui.input("Repository URL or local path", "https://github.com/owner/repo.git");
114
+ if (!source?.trim()) return;
115
+ const name = await ctx.ui.input("Optional friendly name", "");
116
+ const repo = await registerRepo(pi, { source, name: name?.trim() || undefined });
117
+ await syncScoutRmTool();
118
+ ctx.ui.notify(`Pi Scout registered ${repo.name}`, "info");
119
+ return;
120
+ }
121
+
122
+ if (action === "List repositories") {
123
+ const state = await loadPrunedState();
124
+ const text = state.repos.length === 0
125
+ ? "No Pi Scout repositories are currently registered."
126
+ : state.repos.map(formatRepo).join("\n\n");
127
+ await ctx.ui.editor("Pi Scout repositories", text);
128
+ return;
129
+ }
130
+
131
+ if (action === "Remove repository") {
132
+ const state = await loadPrunedState();
133
+ if (state.repos.length === 0) {
134
+ ctx.ui.notify("No Pi Scout repositories are currently registered.", "info");
135
+ return;
136
+ }
137
+ const labels = state.repos.map((repo) => `${repo.name} (${repo.id})`);
138
+ const selected = await ctx.ui.select("Remove repository", labels);
139
+ if (!selected) return;
140
+ const id = selected.match(/\(([^)]+)\)$/)?.[1];
141
+ if (!id) return;
142
+ const deleteClone = await ctx.ui.confirm("Delete local clone?", "Also delete the cloned temporary directory?");
143
+ const removed = await removeRepo(id, { deleteClone });
144
+ await syncScoutRmTool();
145
+ if (removed) ctx.ui.notify(`Removed ${removed.name}${deleteClone ? " and deleted its clone" : " from Pi Scout records"}`, "info");
146
+ }
147
+ }
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./extensions/index.js";
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "pi-scout",
3
+ "version": "0.1.0",
4
+ "description": "Register local reference codebases for Pi agent exploration.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "jvm",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/jvm/pi-mono.git",
11
+ "directory": "packages/pi-scout"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/jvm/pi-mono/issues"
15
+ },
16
+ "homepage": "https://github.com/jvm/pi-mono/tree/main/packages/pi-scout#readme",
17
+ "keywords": [
18
+ "pi-package",
19
+ "pi-extension",
20
+ "pi",
21
+ "scout",
22
+ "codebase-exploration",
23
+ "reference-repos"
24
+ ],
25
+ "exports": {
26
+ ".": "./src/index.ts"
27
+ },
28
+ "pi": {
29
+ "extensions": [
30
+ "./index.ts"
31
+ ]
32
+ },
33
+ "files": [
34
+ "index.ts",
35
+ "extensions",
36
+ "src",
37
+ "README.md",
38
+ "LICENSE",
39
+ "CHANGELOG.md",
40
+ "SECURITY.md",
41
+ "CONTRIBUTING.md",
42
+ "CODE_OF_CONDUCT.md"
43
+ ],
44
+ "scripts": {
45
+ "check": "tsc --noEmit",
46
+ "typecheck": "tsc --noEmit",
47
+ "pack:dry-run": "npm pack --dry-run"
48
+ },
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-coding-agent": "*",
51
+ "typebox": "*"
52
+ },
53
+ "devDependencies": {
54
+ "@earendil-works/pi-coding-agent": "^0.74.0",
55
+ "@types/node": "^25.6.2",
56
+ "typebox": "^1.1.33",
57
+ "typescript": "^6.0.3"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public"
61
+ },
62
+ "engines": {
63
+ "node": ">=20.6.0"
64
+ }
65
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export const PACKAGE_NAME = "pi-scout";
2
+
3
+ export * from "./prompt.js";
4
+ export * from "./repo.js";
5
+ export * from "./state.js";
package/src/prompt.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { ScoutRepo } from "./state.js";
2
+
3
+ export function buildScoutPrompt(repos: ScoutRepo[]): string {
4
+ if (repos.length === 0) return "";
5
+
6
+ const repoList = repos.map((repo) => `- ${repo.name}: ${repo.path}`).join("\n");
7
+
8
+ return `Scout repos:\n${repoList}\nUse as read-only reference codebases when relevant.`;
9
+ }
package/src/repo.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { mkdir, rm } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { platform, tmpdir } from "node:os";
4
+ import { randomUUID } from "node:crypto";
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { loadPrunedState, saveState, type ScoutRepo } from "./state.js";
7
+
8
+ export interface RegisterRepoOptions {
9
+ source: string;
10
+ name?: string;
11
+ branch?: string;
12
+ depth?: number;
13
+ signal?: AbortSignal;
14
+ }
15
+
16
+ export async function registerRepo(pi: ExtensionAPI, options: RegisterRepoOptions): Promise<ScoutRepo> {
17
+ const source = normalizeSource(options.source.trim());
18
+ if (!source) throw new Error("Repository source is required.");
19
+
20
+ const id = randomUUID().slice(0, 12);
21
+ const name = sanitizeName(options.name?.trim() || inferName(source) || `repo-${id}`);
22
+ const root = getScoutCloneRoot();
23
+ const destination = join(root, `${name}-${id}`);
24
+ await mkdir(root, { recursive: true });
25
+
26
+ const args = ["clone"];
27
+ if (options.branch?.trim()) args.push("--branch", options.branch.trim());
28
+ const depth = options.depth && Number.isInteger(options.depth) && options.depth > 0 ? options.depth : 1;
29
+ args.push("--depth", String(depth));
30
+ args.push(source, destination);
31
+
32
+ const result = await pi.exec("git", args, { signal: options.signal, timeout: 120_000 });
33
+ if (result.code !== 0) {
34
+ const stderr = result.stderr?.trim() || result.stdout?.trim() || "git clone failed";
35
+ throw new Error(stderr);
36
+ }
37
+
38
+ const now = new Date().toISOString();
39
+ const repo: ScoutRepo = {
40
+ id,
41
+ name,
42
+ source,
43
+ path: destination,
44
+ branch: options.branch?.trim() || undefined,
45
+ createdAt: now,
46
+ lastSeenAt: now,
47
+ };
48
+
49
+ const state = await loadPrunedState();
50
+ state.repos.push(repo);
51
+ await saveState(state);
52
+ return repo;
53
+ }
54
+
55
+ export async function removeRepo(idOrName: string, options: { deleteClone?: boolean } = {}): Promise<ScoutRepo | undefined> {
56
+ const needle = idOrName.trim();
57
+ if (!needle) return undefined;
58
+
59
+ const state = await loadPrunedState();
60
+ const index = state.repos.findIndex((repo) => repo.id === needle || repo.name === needle);
61
+ if (index === -1) return undefined;
62
+
63
+ const [removed] = state.repos.splice(index, 1);
64
+ await saveState(state);
65
+
66
+ if (options.deleteClone && removed) {
67
+ await rm(removed.path, { recursive: true, force: true });
68
+ }
69
+
70
+ return removed;
71
+ }
72
+
73
+ export function formatRepo(repo: ScoutRepo): string {
74
+ const branch = repo.branch ? ` (${repo.branch})` : "";
75
+ return `${repo.name}${branch}\n id: ${repo.id}\n source: ${repo.source}\n path: ${repo.path}`;
76
+ }
77
+
78
+ function getScoutCloneRoot(): string {
79
+ if (process.env.PI_SCOUT_TMPDIR) return join(process.env.PI_SCOUT_TMPDIR, "pi-scout");
80
+ if (platform() !== "win32") return "/tmp/pi-scout";
81
+ return join(tmpdir(), "pi-scout");
82
+ }
83
+
84
+ function inferName(source: string): string {
85
+ const shorthand = parseGitHubShorthand(source);
86
+ if (shorthand) return shorthand.repo;
87
+
88
+ const withoutTrailingSlash = source.replace(/[\\/]+$/, "");
89
+ const last = basename(withoutTrailingSlash).replace(/\.git$/i, "");
90
+ return last || "repo";
91
+ }
92
+
93
+ function normalizeSource(source: string): string {
94
+ const shorthand = parseGitHubShorthand(source);
95
+ if (!shorthand) return source;
96
+ return `https://github.com/${shorthand.owner}/${shorthand.repo}.git`;
97
+ }
98
+
99
+ function parseGitHubShorthand(source: string): { owner: string; repo: string } | undefined {
100
+ const match = source.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/);
101
+ if (!match) return undefined;
102
+ return { owner: match[1]!, repo: match[2]!.replace(/\.git$/i, "") };
103
+ }
104
+
105
+ function sanitizeName(name: string): string {
106
+ return name
107
+ .toLowerCase()
108
+ .replace(/[^a-z0-9._-]+/g, "-")
109
+ .replace(/^-+|-+$/g, "")
110
+ .slice(0, 64) || "repo";
111
+ }
package/src/state.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+
5
+ export interface ScoutRepo {
6
+ id: string;
7
+ name: string;
8
+ source: string;
9
+ path: string;
10
+ branch?: string;
11
+ createdAt: string;
12
+ lastSeenAt: string;
13
+ }
14
+
15
+ export interface ScoutState {
16
+ repos: ScoutRepo[];
17
+ }
18
+
19
+ const STATE_DIR = join(getAgentDir(), "scout");
20
+ const STATE_PATH = join(STATE_DIR, "repos.json");
21
+
22
+ export function getScoutStatePath(): string {
23
+ return STATE_PATH;
24
+ }
25
+
26
+ export async function loadState(): Promise<ScoutState> {
27
+ try {
28
+ const raw = await readFile(STATE_PATH, "utf8");
29
+ const parsed = JSON.parse(raw) as Partial<ScoutState>;
30
+ return { repos: Array.isArray(parsed.repos) ? parsed.repos.filter(isScoutRepo) : [] };
31
+ } catch (error: any) {
32
+ if (error?.code === "ENOENT") return { repos: [] };
33
+ throw new Error(`Failed to read pi-scout state at ${STATE_PATH}: ${error?.message ?? String(error)}`);
34
+ }
35
+ }
36
+
37
+ export async function saveState(state: ScoutState): Promise<void> {
38
+ await mkdir(STATE_DIR, { recursive: true });
39
+ await writeFile(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, "utf8");
40
+ }
41
+
42
+ export async function pruneMissingRepos(state: ScoutState): Promise<{ state: ScoutState; removed: ScoutRepo[] }> {
43
+ const repos: ScoutRepo[] = [];
44
+ const removed: ScoutRepo[] = [];
45
+ const now = new Date().toISOString();
46
+
47
+ for (const repo of state.repos) {
48
+ if (await pathExists(repo.path)) {
49
+ repos.push({ ...repo, lastSeenAt: now });
50
+ } else {
51
+ removed.push(repo);
52
+ }
53
+ }
54
+
55
+ const next = { repos };
56
+ if (removed.length > 0) await saveState(next);
57
+ return { state: next, removed };
58
+ }
59
+
60
+ export async function loadPrunedState(): Promise<ScoutState> {
61
+ return (await pruneMissingRepos(await loadState())).state;
62
+ }
63
+
64
+ async function pathExists(path: string): Promise<boolean> {
65
+ try {
66
+ await stat(path);
67
+ return true;
68
+ } catch (error: any) {
69
+ if (error?.code === "ENOENT") return false;
70
+ throw error;
71
+ }
72
+ }
73
+
74
+ function isScoutRepo(value: unknown): value is ScoutRepo {
75
+ if (!value || typeof value !== "object") return false;
76
+ const repo = value as Record<string, unknown>;
77
+ return typeof repo.id === "string"
78
+ && typeof repo.name === "string"
79
+ && typeof repo.source === "string"
80
+ && typeof repo.path === "string"
81
+ && typeof repo.createdAt === "string"
82
+ && typeof repo.lastSeenAt === "string";
83
+ }