@f5xc-salesdemos/xcsh 17.5.1 → 18.0.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/package.json +13 -12
- package/scripts/build-info/resolvers.ts +51 -0
- package/scripts/generate-build-info.ts +106 -0
- package/src/internal-urls/build-info-runtime.ts +182 -0
- package/src/internal-urls/build-info.generated.ts +33 -0
- package/src/internal-urls/index.ts +1 -1
- package/src/internal-urls/router.ts +2 -1
- package/src/internal-urls/types.ts +1 -1
- package/src/internal-urls/xcsh-protocol.ts +113 -0
- package/src/prompts/system/system-prompt.md +2 -1
- package/src/sdk.ts +2 -2
- package/src/internal-urls/pi-protocol.ts +0 -84
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "18.0.0",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5xc-salesdemos/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -31,27 +31,28 @@
|
|
|
31
31
|
"xcsh": "src/cli.ts"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"build": "bun --cwd=../stats scripts/generate-client-bundle.ts --generate && bun --cwd=../natives run embed:native && bun build --compile --define PI_COMPILED=true --external mupdf --root ../.. ./src/cli.ts --outfile dist/xcsh && bun --cwd=../natives run embed:native --reset && bun --cwd=../stats scripts/generate-client-bundle.ts --reset",
|
|
34
|
+
"build": "bun run generate-build-info && bun --cwd=../stats scripts/generate-client-bundle.ts --generate && bun --cwd=../natives run embed:native && bun build --compile --define PI_COMPILED=true --external mupdf --root ../.. ./src/cli.ts --outfile dist/xcsh && bun --cwd=../natives run embed:native --reset && bun --cwd=../stats scripts/generate-client-bundle.ts --reset",
|
|
35
35
|
"check": "biome check . && bun run check:types",
|
|
36
|
-
"check:types": "tsgo -p tsconfig.json --noEmit",
|
|
36
|
+
"check:types": "bun run generate-build-info && tsgo -p tsconfig.json --noEmit",
|
|
37
37
|
"lint": "biome lint .",
|
|
38
|
-
"test": "bun test --max-concurrency 4",
|
|
39
|
-
"fix": "biome check --write --unsafe . && bun run format-prompts && bun run generate-docs-index",
|
|
38
|
+
"test": "bun run generate-build-info && bun test --max-concurrency 4",
|
|
39
|
+
"fix": "biome check --write --unsafe . && bun run format-prompts && bun run generate-docs-index && bun run generate-build-info",
|
|
40
40
|
"fmt": "biome format --write . && bun run format-prompts",
|
|
41
41
|
"format-prompts": "bun scripts/format-prompts.ts",
|
|
42
42
|
"generate-docs-index": "bun scripts/generate-docs-index.ts",
|
|
43
|
-
"
|
|
43
|
+
"generate-build-info": "bun scripts/generate-build-info.ts",
|
|
44
|
+
"prepack": "bun scripts/generate-docs-index.ts && bun scripts/generate-build-info.ts",
|
|
44
45
|
"generate-template": "bun scripts/generate-template.ts"
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
47
48
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
48
49
|
"@mozilla/readability": "^0.6",
|
|
49
|
-
"@f5xc-salesdemos/xcsh-stats": "
|
|
50
|
-
"@f5xc-salesdemos/pi-agent-core": "
|
|
51
|
-
"@f5xc-salesdemos/pi-ai": "
|
|
52
|
-
"@f5xc-salesdemos/pi-natives": "
|
|
53
|
-
"@f5xc-salesdemos/pi-tui": "
|
|
54
|
-
"@f5xc-salesdemos/pi-utils": "
|
|
50
|
+
"@f5xc-salesdemos/xcsh-stats": "18.0.0",
|
|
51
|
+
"@f5xc-salesdemos/pi-agent-core": "18.0.0",
|
|
52
|
+
"@f5xc-salesdemos/pi-ai": "18.0.0",
|
|
53
|
+
"@f5xc-salesdemos/pi-natives": "18.0.0",
|
|
54
|
+
"@f5xc-salesdemos/pi-tui": "18.0.0",
|
|
55
|
+
"@f5xc-salesdemos/pi-utils": "18.0.0",
|
|
55
56
|
"@sinclair/typebox": "^0.34",
|
|
56
57
|
"@xterm/headless": "^6.0",
|
|
57
58
|
"ajv": "^8.18",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface EnvLike {
|
|
2
|
+
readonly XCSH_BUILD_COMMIT?: string;
|
|
3
|
+
readonly XCSH_BUILD_BRANCH?: string;
|
|
4
|
+
readonly XCSH_BUILD_TAG?: string;
|
|
5
|
+
readonly XCSH_BUILD_PR?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type GitFn = (args: string[]) => Promise<string>;
|
|
9
|
+
export type GhFn = (sha: string) => Promise<string>;
|
|
10
|
+
|
|
11
|
+
function pick(value: string | undefined): string {
|
|
12
|
+
return value?.trim() ?? "";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function resolveCommit(env: EnvLike, git: GitFn): Promise<string> {
|
|
16
|
+
return pick(env.XCSH_BUILD_COMMIT) || (await git(["rev-parse", "HEAD"]));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function resolveBranch(env: EnvLike, git: GitFn): Promise<string> {
|
|
20
|
+
const override = pick(env.XCSH_BUILD_BRANCH);
|
|
21
|
+
if (override) return override;
|
|
22
|
+
|
|
23
|
+
const local = await git(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
24
|
+
if (local && local !== "HEAD") return local;
|
|
25
|
+
|
|
26
|
+
const remote = await git(["branch", "-r", "--contains", "HEAD"]);
|
|
27
|
+
for (const raw of remote.split("\n")) {
|
|
28
|
+
const line = raw.trim();
|
|
29
|
+
if (!line || line.includes("->") || line === "HEAD") continue;
|
|
30
|
+
const stripped = line.replace(/^origin\//, "");
|
|
31
|
+
if (stripped && stripped !== "HEAD") return stripped;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return "unknown";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function resolveTag(env: EnvLike, git: GitFn): Promise<string> {
|
|
38
|
+
return pick(env.XCSH_BUILD_TAG) || (await git(["describe", "--exact-match", "--tags", "HEAD"]));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function resolveDirty(_env: EnvLike, git: GitFn): Promise<boolean> {
|
|
42
|
+
const status = await git(["status", "--porcelain"]);
|
|
43
|
+
return status.length > 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function resolvePrNumber(sha: string, env: EnvLike, gh: GhFn): Promise<string> {
|
|
47
|
+
const override = pick(env.XCSH_BUILD_PR);
|
|
48
|
+
if (override) return override;
|
|
49
|
+
if (!sha) return "";
|
|
50
|
+
return await gh(sha);
|
|
51
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { $ } from "bun";
|
|
5
|
+
import {
|
|
6
|
+
type EnvLike,
|
|
7
|
+
resolveBranch,
|
|
8
|
+
resolveCommit,
|
|
9
|
+
resolveDirty,
|
|
10
|
+
resolvePrNumber,
|
|
11
|
+
resolveTag,
|
|
12
|
+
} from "./build-info/resolvers";
|
|
13
|
+
|
|
14
|
+
const repoRoot = path.resolve(import.meta.dir, "../../..");
|
|
15
|
+
const outputPath = path.resolve(import.meta.dir, "../src/internal-urls/build-info.generated.ts");
|
|
16
|
+
const utilsPackageJsonPath = path.resolve(repoRoot, "packages/utils/package.json");
|
|
17
|
+
|
|
18
|
+
const REPO_SLUG = "f5xc-salesdemos/xcsh";
|
|
19
|
+
const REPO_URL = `https://github.com/${REPO_SLUG}`;
|
|
20
|
+
|
|
21
|
+
async function git(args: string[]): Promise<string> {
|
|
22
|
+
try {
|
|
23
|
+
const result = await $`git ${args}`.cwd(repoRoot).quiet();
|
|
24
|
+
return result.stdout.toString().trim();
|
|
25
|
+
} catch {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function ghPrForSha(sha: string): Promise<string> {
|
|
31
|
+
try {
|
|
32
|
+
const result =
|
|
33
|
+
await $`gh pr list --search ${sha} --state merged --json number --limit 1 --jq ${".[0].number // empty"}`
|
|
34
|
+
.cwd(repoRoot)
|
|
35
|
+
.quiet();
|
|
36
|
+
return result.stdout.toString().trim();
|
|
37
|
+
} catch {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function resolveCommitDate(sha: string): Promise<string> {
|
|
43
|
+
if (!sha) return "";
|
|
44
|
+
return await git(["log", "-1", "--format=%cI", sha]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const env = process.env as EnvLike;
|
|
48
|
+
|
|
49
|
+
const utilsPackageJson = (await Bun.file(utilsPackageJsonPath).json()) as { version: string };
|
|
50
|
+
const version = utilsPackageJson.version;
|
|
51
|
+
const commit = await resolveCommit(env, git);
|
|
52
|
+
const shortCommit = commit ? commit.slice(0, 7) : "";
|
|
53
|
+
const branch = await resolveBranch(env, git);
|
|
54
|
+
const tag = await resolveTag(env, git);
|
|
55
|
+
const commitDate = await resolveCommitDate(commit);
|
|
56
|
+
const dirty = await resolveDirty(env, git);
|
|
57
|
+
const prNumber = await resolvePrNumber(commit, env, ghPrForSha);
|
|
58
|
+
const buildDate = new Date().toISOString();
|
|
59
|
+
const releaseUrl = `${REPO_URL}/releases/tag/v${version}`;
|
|
60
|
+
const commitUrl = commit ? `${REPO_URL}/commit/${commit}` : REPO_URL;
|
|
61
|
+
|
|
62
|
+
const output = [
|
|
63
|
+
"// Auto-generated by scripts/generate-build-info.ts - DO NOT EDIT",
|
|
64
|
+
"",
|
|
65
|
+
"export interface BuildInfo {",
|
|
66
|
+
"\treadonly version: string;",
|
|
67
|
+
"\treadonly commit: string;",
|
|
68
|
+
"\treadonly shortCommit: string;",
|
|
69
|
+
"\treadonly branch: string;",
|
|
70
|
+
"\treadonly tag: string;",
|
|
71
|
+
"\treadonly commitDate: string;",
|
|
72
|
+
"\treadonly buildDate: string;",
|
|
73
|
+
"\treadonly dirty: boolean;",
|
|
74
|
+
"\treadonly prNumber: string;",
|
|
75
|
+
"\treadonly repoUrl: string;",
|
|
76
|
+
"\treadonly repoSlug: string;",
|
|
77
|
+
"\treadonly commitUrl: string;",
|
|
78
|
+
"\treadonly releaseUrl: string;",
|
|
79
|
+
"}",
|
|
80
|
+
"",
|
|
81
|
+
`export const BUILD_INFO: BuildInfo = ${JSON.stringify(
|
|
82
|
+
{
|
|
83
|
+
version,
|
|
84
|
+
commit,
|
|
85
|
+
shortCommit,
|
|
86
|
+
branch,
|
|
87
|
+
tag,
|
|
88
|
+
commitDate,
|
|
89
|
+
buildDate,
|
|
90
|
+
dirty,
|
|
91
|
+
prNumber,
|
|
92
|
+
repoUrl: REPO_URL,
|
|
93
|
+
repoSlug: REPO_SLUG,
|
|
94
|
+
commitUrl,
|
|
95
|
+
releaseUrl,
|
|
96
|
+
},
|
|
97
|
+
null,
|
|
98
|
+
"\t",
|
|
99
|
+
)};`,
|
|
100
|
+
"",
|
|
101
|
+
].join("\n");
|
|
102
|
+
|
|
103
|
+
await Bun.write(outputPath, output);
|
|
104
|
+
console.log(
|
|
105
|
+
`Generated ${path.relative(process.cwd(), outputPath)} (v${version}, ${shortCommit || "no-commit"}, branch=${branch}${tag ? `, tag=${tag}` : ""}${dirty ? ", dirty" : ""})`,
|
|
106
|
+
);
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { $ } from "bun";
|
|
4
|
+
import { BUILD_INFO, type BuildInfo } from "./build-info.generated";
|
|
5
|
+
|
|
6
|
+
export type BuildInfoSource = "compiled" | "live-git" | "embedded-fallback";
|
|
7
|
+
|
|
8
|
+
export interface RuntimeBuildInfo extends BuildInfo {
|
|
9
|
+
readonly source: BuildInfoSource;
|
|
10
|
+
readonly resolvedAt: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RuntimeBuildInfoDeps {
|
|
14
|
+
readonly isCompiled: boolean;
|
|
15
|
+
readonly gitAvailable: () => boolean;
|
|
16
|
+
readonly git: (args: string[]) => Promise<string>;
|
|
17
|
+
readonly now: () => Date;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function shortOf(sha: string): string {
|
|
21
|
+
return sha ? sha.slice(0, 7) : "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function commitUrl(repoUrl: string, commit: string): string {
|
|
25
|
+
return commit ? `${repoUrl}/commit/${commit}` : repoUrl;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function firstRemoteBranch(output: string): string {
|
|
29
|
+
for (const raw of output.split("\n")) {
|
|
30
|
+
const line = raw.trim();
|
|
31
|
+
if (!line || line.includes("->") || line === "HEAD") continue;
|
|
32
|
+
const stripped = line.replace(/^origin\//, "");
|
|
33
|
+
if (stripped && stripped !== "HEAD") return stripped;
|
|
34
|
+
}
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function liveBranch(git: RuntimeBuildInfoDeps["git"]): Promise<string> {
|
|
39
|
+
const abbrev = await git(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
40
|
+
if (abbrev && abbrev !== "HEAD") return abbrev;
|
|
41
|
+
const remote = await git(["branch", "-r", "--contains", "HEAD"]);
|
|
42
|
+
return firstRemoteBranch(remote);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function resolveRuntimeBuildInfo(
|
|
46
|
+
embedded: BuildInfo,
|
|
47
|
+
deps: RuntimeBuildInfoDeps,
|
|
48
|
+
): Promise<RuntimeBuildInfo> {
|
|
49
|
+
const resolvedAt = deps.now().toISOString();
|
|
50
|
+
|
|
51
|
+
if (deps.isCompiled) {
|
|
52
|
+
return { ...embedded, source: "compiled", resolvedAt };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!deps.gitAvailable()) {
|
|
56
|
+
return { ...embedded, source: "embedded-fallback", resolvedAt };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const commit = (await deps.git(["rev-parse", "HEAD"])) || embedded.commit;
|
|
60
|
+
const branch = (await liveBranch(deps.git)) || embedded.branch;
|
|
61
|
+
const tag = await deps.git(["describe", "--exact-match", "--tags", "HEAD"]);
|
|
62
|
+
const status = await deps.git(["status", "--porcelain"]);
|
|
63
|
+
const dirty = status.length > 0;
|
|
64
|
+
const commitDate = (await deps.git(["log", "-1", "--format=%cI", "HEAD"])) || embedded.commitDate;
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
version: embedded.version,
|
|
68
|
+
commit,
|
|
69
|
+
shortCommit: shortOf(commit),
|
|
70
|
+
branch,
|
|
71
|
+
tag,
|
|
72
|
+
commitDate,
|
|
73
|
+
buildDate: embedded.buildDate,
|
|
74
|
+
dirty,
|
|
75
|
+
prNumber: embedded.prNumber,
|
|
76
|
+
repoUrl: embedded.repoUrl,
|
|
77
|
+
repoSlug: embedded.repoSlug,
|
|
78
|
+
commitUrl: commitUrl(embedded.repoUrl, commit),
|
|
79
|
+
releaseUrl: embedded.releaseUrl,
|
|
80
|
+
source: "live-git",
|
|
81
|
+
resolvedAt,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function renderAboutDoc(info: RuntimeBuildInfo): string {
|
|
86
|
+
return [
|
|
87
|
+
"# xcsh — identity and build fingerprint",
|
|
88
|
+
"",
|
|
89
|
+
"You are running inside xcsh, a coworker-style CLI for F5 sales engineers:",
|
|
90
|
+
"demos, docs, research, MEDDPICC, customer meeting prep, and day-to-day SE tasks.",
|
|
91
|
+
"This document is the authoritative answer when the user asks about xcsh itself.",
|
|
92
|
+
"",
|
|
93
|
+
"## Build fingerprint",
|
|
94
|
+
"",
|
|
95
|
+
`- Version: \`${info.version}\``,
|
|
96
|
+
`- Commit: \`${info.shortCommit || "unknown"}\` (full: \`${info.commit || "unknown"}\`)`,
|
|
97
|
+
`- Branch: \`${info.branch || "unknown"}\``,
|
|
98
|
+
`- Tag: ${info.tag ? `\`${info.tag}\`` : "(not a tagged build)"}`,
|
|
99
|
+
`- Commit date: ${info.commitDate || "unknown"}`,
|
|
100
|
+
`- Build date: ${info.buildDate || "unknown"}`,
|
|
101
|
+
`- Built from dirty tree: ${info.dirty ? "yes" : "no"}`,
|
|
102
|
+
`- PR that shipped this version: ${info.prNumber ? `#${info.prNumber}` : "unknown (resolve via gh if needed)"}`,
|
|
103
|
+
`- Provenance source: \`${info.source}\` (resolved at ${info.resolvedAt})`,
|
|
104
|
+
"",
|
|
105
|
+
"## Source of truth",
|
|
106
|
+
"",
|
|
107
|
+
`- Repository: ${info.repoUrl}`,
|
|
108
|
+
`- Issues: ${info.repoUrl}/issues`,
|
|
109
|
+
`- Pull requests: ${info.repoUrl}/pulls`,
|
|
110
|
+
`- This commit on GitHub: ${info.commitUrl}`,
|
|
111
|
+
`- Release for this version: ${info.releaseUrl}`,
|
|
112
|
+
"",
|
|
113
|
+
"## What to do when asked about xcsh itself",
|
|
114
|
+
"",
|
|
115
|
+
"1. Confirm the user is running the version above. If unsure, ask them to run `xcsh --version`.",
|
|
116
|
+
"2. Check recent changes with `gh pr list --repo f5xc-salesdemos/xcsh --base main --state merged --limit 20`",
|
|
117
|
+
" or `git log --oneline -n 20` if you have a local clone. A fix may already be on `main`.",
|
|
118
|
+
"3. If behavior contradicts `xcsh://…` docs, read the actual source under the repo above to determine",
|
|
119
|
+
" whether the binary is wrong or the doc is stale.",
|
|
120
|
+
"4. Classify the report as one of: **bug**, **feature**, **docs-drift**, or **config/usage**.",
|
|
121
|
+
"5. Offer to file it with",
|
|
122
|
+
" `gh issue create --repo f5xc-salesdemos/xcsh --title ... --body ...`, referencing the commit above.",
|
|
123
|
+
"",
|
|
124
|
+
"## What NOT to assume",
|
|
125
|
+
"",
|
|
126
|
+
"- Do not guess the repo URL, version, or commit — use the values above.",
|
|
127
|
+
"- Do not invent recent changes; fetch them at runtime via `gh` or `git`.",
|
|
128
|
+
"- Do not read this document unless the user asked about xcsh itself.",
|
|
129
|
+
"",
|
|
130
|
+
].join("\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Bun-embedded module URL markers. Mirrors the native addon loader
|
|
134
|
+
// (see xcsh://natives-addon-loader-runtime.md) so compiled-mode detection stays
|
|
135
|
+
// consistent across the codebase. Update all three in lockstep if Bun changes them.
|
|
136
|
+
const COMPILED_URL_MARKERS = ["$bunfs", "~BUN", "%7EBUN"] as const;
|
|
137
|
+
|
|
138
|
+
export function detectCompiledRuntime(
|
|
139
|
+
metaUrl: string,
|
|
140
|
+
env: Readonly<Record<string, string | undefined>> = {},
|
|
141
|
+
): boolean {
|
|
142
|
+
if (env.PI_COMPILED) return true;
|
|
143
|
+
return COMPILED_URL_MARKERS.some(marker => metaUrl.includes(marker));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function findGitRoot(startDir: string, fsExists: (p: string) => boolean = fs.existsSync): string | null {
|
|
147
|
+
let current = path.resolve(startDir);
|
|
148
|
+
while (true) {
|
|
149
|
+
if (fsExists(path.join(current, ".git"))) return current;
|
|
150
|
+
const parent = path.dirname(current);
|
|
151
|
+
if (parent === current) return null;
|
|
152
|
+
current = parent;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function defaultRuntimeDeps(): RuntimeBuildInfoDeps {
|
|
157
|
+
const isCompiled = detectCompiledRuntime(import.meta.url, Bun.env);
|
|
158
|
+
const gitRoot = isCompiled ? null : findGitRoot(import.meta.dir);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
isCompiled,
|
|
162
|
+
gitAvailable: () => gitRoot !== null,
|
|
163
|
+
git: async (args: string[]): Promise<string> => {
|
|
164
|
+
if (!gitRoot) return "";
|
|
165
|
+
try {
|
|
166
|
+
const result = await $`git ${args}`.cwd(gitRoot).quiet();
|
|
167
|
+
return result.stdout.toString().trim();
|
|
168
|
+
} catch {
|
|
169
|
+
return "";
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
now: () => new Date(),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Intentionally no cache. `xcsh://about` is invoked once per xcsh-related question
|
|
177
|
+
// at agent-tool-call granularity; stale fingerprints after branch-switch / dirty-tree
|
|
178
|
+
// changes would silently lie under source-mode. Re-resolving costs ~30ms of git subprocess
|
|
179
|
+
// time in source mode and ~0ms in compiled mode (where we return embedded BUILD_INFO).
|
|
180
|
+
export function getRuntimeBuildInfo(): Promise<RuntimeBuildInfo> {
|
|
181
|
+
return resolveRuntimeBuildInfo(BUILD_INFO, defaultRuntimeDeps());
|
|
182
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Auto-generated by scripts/generate-build-info.ts - DO NOT EDIT
|
|
2
|
+
|
|
3
|
+
export interface BuildInfo {
|
|
4
|
+
readonly version: string;
|
|
5
|
+
readonly commit: string;
|
|
6
|
+
readonly shortCommit: string;
|
|
7
|
+
readonly branch: string;
|
|
8
|
+
readonly tag: string;
|
|
9
|
+
readonly commitDate: string;
|
|
10
|
+
readonly buildDate: string;
|
|
11
|
+
readonly dirty: boolean;
|
|
12
|
+
readonly prNumber: string;
|
|
13
|
+
readonly repoUrl: string;
|
|
14
|
+
readonly repoSlug: string;
|
|
15
|
+
readonly commitUrl: string;
|
|
16
|
+
readonly releaseUrl: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const BUILD_INFO: BuildInfo = {
|
|
20
|
+
"version": "18.0.0",
|
|
21
|
+
"commit": "693d44240bf4f157a21a15357ff00114c90a3fe5",
|
|
22
|
+
"shortCommit": "693d442",
|
|
23
|
+
"branch": "main",
|
|
24
|
+
"tag": "v18.0.0",
|
|
25
|
+
"commitDate": "2026-04-19T21:26:31Z",
|
|
26
|
+
"buildDate": "2026-04-19T21:47:02.421Z",
|
|
27
|
+
"dirty": false,
|
|
28
|
+
"prNumber": "",
|
|
29
|
+
"repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
|
|
30
|
+
"repoSlug": "f5xc-salesdemos/xcsh",
|
|
31
|
+
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/693d44240bf4f157a21a15357ff00114c90a3fe5",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.0.0"
|
|
33
|
+
};
|
|
@@ -28,8 +28,8 @@ export * from "./local-protocol";
|
|
|
28
28
|
export * from "./mcp-protocol";
|
|
29
29
|
export * from "./memory-protocol";
|
|
30
30
|
export * from "./parse";
|
|
31
|
-
export * from "./pi-protocol";
|
|
32
31
|
export * from "./router";
|
|
33
32
|
export * from "./rule-protocol";
|
|
34
33
|
export * from "./skill-protocol";
|
|
35
34
|
export type * from "./types";
|
|
35
|
+
export * from "./xcsh-protocol";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Internal URL router for internal protocols
|
|
2
|
+
* Internal URL router for internal protocols
|
|
3
|
+
* (agent://, artifact://, memory://, skill://, rule://, mcp://, xcsh://, local://, jobs://).
|
|
3
4
|
*/
|
|
4
5
|
import { parseInternalUrl } from "./parse";
|
|
5
6
|
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Types for the internal URL routing system.
|
|
3
3
|
*
|
|
4
|
-
* Internal URLs (agent://, artifact://, memory://, skill://, rule://, mcp://,
|
|
4
|
+
* Internal URLs (agent://, artifact://, memory://, skill://, rule://, mcp://, xcsh://, local://) are resolved by tools like read,
|
|
5
5
|
* providing access to agent outputs and server resources without exposing filesystem paths.
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol handler for xcsh:// URLs.
|
|
3
|
+
*
|
|
4
|
+
* Serves statically embedded documentation files bundled at build time,
|
|
5
|
+
* plus the runtime-resolved `about` identity doc.
|
|
6
|
+
*
|
|
7
|
+
* URL forms:
|
|
8
|
+
* - xcsh:// - Lists all available documentation files
|
|
9
|
+
* - xcsh://<file>.md - Reads a specific documentation file
|
|
10
|
+
* - xcsh://about - Identity fingerprint (version, commit, branch, repo)
|
|
11
|
+
*/
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { getRuntimeBuildInfo, type RuntimeBuildInfo, renderAboutDoc } from "./build-info-runtime";
|
|
14
|
+
import { EMBEDDED_DOC_FILENAMES, EMBEDDED_DOCS } from "./docs-index.generated";
|
|
15
|
+
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
|
|
16
|
+
|
|
17
|
+
const SCHEME_PREFIX = "xcsh://";
|
|
18
|
+
const ABOUT_ROUTE = "about";
|
|
19
|
+
|
|
20
|
+
export interface InternalDocsProtocolOptions {
|
|
21
|
+
/** Override runtime build-info resolution. Primarily for tests. */
|
|
22
|
+
readonly resolveBuildInfo?: () => Promise<RuntimeBuildInfo>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Handler for the xcsh:// internal documentation protocol.
|
|
27
|
+
*
|
|
28
|
+
* Resolves documentation file names to their content, lists available docs,
|
|
29
|
+
* and serves the runtime identity doc at xcsh://about.
|
|
30
|
+
*/
|
|
31
|
+
export class InternalDocsProtocolHandler implements ProtocolHandler {
|
|
32
|
+
readonly scheme = "xcsh";
|
|
33
|
+
readonly #resolveBuildInfo: () => Promise<RuntimeBuildInfo>;
|
|
34
|
+
|
|
35
|
+
constructor(options: InternalDocsProtocolOptions = {}) {
|
|
36
|
+
this.#resolveBuildInfo = options.resolveBuildInfo ?? getRuntimeBuildInfo;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async resolve(url: InternalUrl): Promise<InternalResource> {
|
|
40
|
+
const host = url.rawHost || url.hostname;
|
|
41
|
+
const pathname = url.rawPathname ?? url.pathname;
|
|
42
|
+
const filename = host ? (pathname && pathname !== "/" ? host + pathname : host) : "";
|
|
43
|
+
|
|
44
|
+
if (!filename) {
|
|
45
|
+
return this.#listDocs(url);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return this.#readDoc(filename, url);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async #listDocs(url: InternalUrl): Promise<InternalResource> {
|
|
52
|
+
if (EMBEDDED_DOC_FILENAMES.length === 0) {
|
|
53
|
+
throw new Error("No documentation files found");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const syntheticEntry = `- [${ABOUT_ROUTE}](${SCHEME_PREFIX}${ABOUT_ROUTE}) — identity and build fingerprint`;
|
|
57
|
+
const listing = [syntheticEntry, ...EMBEDDED_DOC_FILENAMES.map(f => `- [${f}](${SCHEME_PREFIX}${f})`)].join("\n");
|
|
58
|
+
const totalCount = EMBEDDED_DOC_FILENAMES.length + 1;
|
|
59
|
+
const content = `# Documentation\n\n${totalCount} files available:\n\n${listing}\n`;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
url: url.href,
|
|
63
|
+
content,
|
|
64
|
+
contentType: "text/markdown",
|
|
65
|
+
size: Buffer.byteLength(content, "utf-8"),
|
|
66
|
+
sourcePath: SCHEME_PREFIX,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async #readDoc(filename: string, url: InternalUrl): Promise<InternalResource> {
|
|
71
|
+
if (path.isAbsolute(filename)) {
|
|
72
|
+
throw new Error(`Absolute paths are not allowed in ${SCHEME_PREFIX} URLs`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const normalized = path.posix.normalize(filename.replaceAll("\\", "/"));
|
|
76
|
+
if (normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
77
|
+
throw new Error(`Path traversal (..) is not allowed in ${SCHEME_PREFIX} URLs`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (normalized === ABOUT_ROUTE || normalized === `${ABOUT_ROUTE}.md`) {
|
|
81
|
+
const info = await this.#resolveBuildInfo();
|
|
82
|
+
const content = renderAboutDoc(info);
|
|
83
|
+
return {
|
|
84
|
+
url: url.href,
|
|
85
|
+
content,
|
|
86
|
+
contentType: "text/markdown",
|
|
87
|
+
size: Buffer.byteLength(content, "utf-8"),
|
|
88
|
+
sourcePath: `${SCHEME_PREFIX}${ABOUT_ROUTE}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const content = EMBEDDED_DOCS[normalized];
|
|
93
|
+
if (content === undefined) {
|
|
94
|
+
const lookup = normalized.replace(/\.md$/, "");
|
|
95
|
+
const suggestions = EMBEDDED_DOC_FILENAMES.filter(
|
|
96
|
+
f => f.includes(lookup) || lookup.includes(f.replace(/\.md$/, "")),
|
|
97
|
+
).slice(0, 5);
|
|
98
|
+
const suffix =
|
|
99
|
+
suggestions.length > 0
|
|
100
|
+
? `\nDid you mean: ${suggestions.join(", ")}`
|
|
101
|
+
: `\nUse ${SCHEME_PREFIX} to list available files.`;
|
|
102
|
+
throw new Error(`Documentation file not found: ${filename}${suffix}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
url: url.href,
|
|
107
|
+
content,
|
|
108
|
+
contentType: "text/markdown",
|
|
109
|
+
size: Buffer.byteLength(content, "utf-8"),
|
|
110
|
+
sourcePath: `${SCHEME_PREFIX}${normalized}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -142,7 +142,8 @@ Most tools resolve custom protocol URLs to internal resources (not web URLs):
|
|
|
142
142
|
- `local://<TITLE>.md` — Finalized plan artifact created after `exit_plan_mode` approval
|
|
143
143
|
- `jobs://<job-id>` — Specific job status and result
|
|
144
144
|
- `mcp://<resource-uri>` — MCP resource from a connected server; matched against exact resource URIs first, then RFC 6570 URI templates advertised by connected servers
|
|
145
|
-
- `
|
|
145
|
+
- `xcsh://..` — Internal documentation files about xcsh; you **MUST NOT** read them unless the user asks about xcsh itself: its SDK, extensions, themes, skills, TUI, keybindings, or configuration
|
|
146
|
+
- `xcsh://about` — authoritative identity and build fingerprint for xcsh itself (version, commit, tag, branch, repo). Read it whenever the question is about xcsh itself, not about the user's own codebase.
|
|
146
147
|
|
|
147
148
|
In `bash`, URIs auto-resolve to filesystem paths (e.g., `python skill://my-skill/scripts/init.py`).
|
|
148
149
|
|
package/src/sdk.ts
CHANGED
|
@@ -63,12 +63,12 @@ import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal }
|
|
|
63
63
|
import {
|
|
64
64
|
AgentProtocolHandler,
|
|
65
65
|
ArtifactProtocolHandler,
|
|
66
|
+
InternalDocsProtocolHandler,
|
|
66
67
|
InternalUrlRouter,
|
|
67
68
|
JobsProtocolHandler,
|
|
68
69
|
LocalProtocolHandler,
|
|
69
70
|
McpProtocolHandler,
|
|
70
71
|
MemoryProtocolHandler,
|
|
71
|
-
PiProtocolHandler,
|
|
72
72
|
RuleProtocolHandler,
|
|
73
73
|
SkillProtocolHandler,
|
|
74
74
|
} from "./internal-urls";
|
|
@@ -1056,7 +1056,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1056
1056
|
getRules: () => [...rulebookRules, ...alwaysApplyRules],
|
|
1057
1057
|
}),
|
|
1058
1058
|
);
|
|
1059
|
-
internalRouter.register(new
|
|
1059
|
+
internalRouter.register(new InternalDocsProtocolHandler());
|
|
1060
1060
|
internalRouter.register(new JobsProtocolHandler({ getAsyncJobManager: () => asyncJobManager }));
|
|
1061
1061
|
internalRouter.register(new McpProtocolHandler({ getMcpManager: () => mcpManager }));
|
|
1062
1062
|
toolSession.internalRouter = internalRouter;
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Protocol handler for pi:// URLs.
|
|
3
|
-
*
|
|
4
|
-
* Serves statically embedded documentation files bundled at build time.
|
|
5
|
-
*
|
|
6
|
-
* URL forms:
|
|
7
|
-
* - pi:// - Lists all available documentation files
|
|
8
|
-
* - pi://<file>.md - Reads a specific documentation file
|
|
9
|
-
*/
|
|
10
|
-
import * as path from "node:path";
|
|
11
|
-
import { EMBEDDED_DOC_FILENAMES, EMBEDDED_DOCS } from "./docs-index.generated";
|
|
12
|
-
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Handler for pi:// URLs.
|
|
16
|
-
*
|
|
17
|
-
* Resolves documentation file names to their content, or lists available docs.
|
|
18
|
-
*/
|
|
19
|
-
export class PiProtocolHandler implements ProtocolHandler {
|
|
20
|
-
readonly scheme = "pi";
|
|
21
|
-
|
|
22
|
-
async resolve(url: InternalUrl): Promise<InternalResource> {
|
|
23
|
-
// Extract filename from host + path
|
|
24
|
-
const host = url.rawHost || url.hostname;
|
|
25
|
-
const pathname = url.rawPathname ?? url.pathname;
|
|
26
|
-
const filename = host ? (pathname && pathname !== "/" ? host + pathname : host) : "";
|
|
27
|
-
|
|
28
|
-
if (!filename) {
|
|
29
|
-
return this.#listDocs(url);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return this.#readDoc(filename, url);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async #listDocs(url: InternalUrl): Promise<InternalResource> {
|
|
36
|
-
if (EMBEDDED_DOC_FILENAMES.length === 0) {
|
|
37
|
-
throw new Error("No documentation files found");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const listing = EMBEDDED_DOC_FILENAMES.map(f => `- [${f}](pi://${f})`).join("\n");
|
|
41
|
-
const content = `# Documentation\n\n${EMBEDDED_DOC_FILENAMES.length} files available:\n\n${listing}\n`;
|
|
42
|
-
|
|
43
|
-
return {
|
|
44
|
-
url: url.href,
|
|
45
|
-
content,
|
|
46
|
-
contentType: "text/markdown",
|
|
47
|
-
size: Buffer.byteLength(content, "utf-8"),
|
|
48
|
-
sourcePath: "pi://",
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async #readDoc(filename: string, url: InternalUrl): Promise<InternalResource> {
|
|
53
|
-
// Validate: no traversal, no absolute paths
|
|
54
|
-
if (path.isAbsolute(filename)) {
|
|
55
|
-
throw new Error("Absolute paths are not allowed in pi:// URLs");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const normalized = path.posix.normalize(filename.replaceAll("\\", "/"));
|
|
59
|
-
if (normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
60
|
-
throw new Error("Path traversal (..) is not allowed in pi:// URLs");
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const content = EMBEDDED_DOCS[normalized];
|
|
64
|
-
if (content === undefined) {
|
|
65
|
-
const lookup = normalized.replace(/\.md$/, "");
|
|
66
|
-
const suggestions = EMBEDDED_DOC_FILENAMES.filter(
|
|
67
|
-
f => f.includes(lookup) || lookup.includes(f.replace(/\.md$/, "")),
|
|
68
|
-
).slice(0, 5);
|
|
69
|
-
const suffix =
|
|
70
|
-
suggestions.length > 0
|
|
71
|
-
? `\nDid you mean: ${suggestions.join(", ")}`
|
|
72
|
-
: "\nUse pi:// to list available files.";
|
|
73
|
-
throw new Error(`Documentation file not found: ${filename}${suffix}`);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return {
|
|
77
|
-
url: url.href,
|
|
78
|
-
content,
|
|
79
|
-
contentType: "text/markdown",
|
|
80
|
-
size: Buffer.byteLength(content, "utf-8"),
|
|
81
|
-
sourcePath: `pi://${normalized}`,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
}
|