@datename/fastcode-termux 0.0.0-stage → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +400 -2
- package/bin/fastcode.mjs +59 -0
- package/dist/agent/guards.d.ts +35 -0
- package/dist/agent/guards.js +74 -0
- package/dist/agent/limits.d.ts +24 -0
- package/dist/agent/limits.js +71 -0
- package/dist/agent/protocol.d.ts +4 -0
- package/dist/agent/protocol.js +126 -0
- package/dist/agent/tools.d.ts +1 -0
- package/dist/agent/tools.js +377 -0
- package/dist/app.d.ts +4 -0
- package/dist/app.js +476 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +518 -0
- package/dist/commands/about.d.ts +8 -0
- package/dist/commands/about.js +16 -0
- package/dist/commands/agent.d.ts +11 -0
- package/dist/commands/agent.js +18 -0
- package/dist/commands/clear.d.ts +11 -0
- package/dist/commands/clear.js +9 -0
- package/dist/commands/config.d.ts +7 -0
- package/dist/commands/config.js +24 -0
- package/dist/commands/cost.d.ts +9 -0
- package/dist/commands/cost.js +13 -0
- package/dist/commands/cwd.d.ts +7 -0
- package/dist/commands/cwd.js +7 -0
- package/dist/commands/exit.d.ts +9 -0
- package/dist/commands/exit.js +8 -0
- package/dist/commands/help.d.ts +6 -0
- package/dist/commands/help.js +18 -0
- package/dist/commands/index.d.ts +9 -0
- package/dist/commands/index.js +58 -0
- package/dist/commands/login.d.ts +16 -0
- package/dist/commands/login.js +61 -0
- package/dist/commands/logout.d.ts +9 -0
- package/dist/commands/logout.js +53 -0
- package/dist/commands/model.d.ts +11 -0
- package/dist/commands/model.js +30 -0
- package/dist/commands/plugin.d.ts +11 -0
- package/dist/commands/plugin.js +74 -0
- package/dist/commands/provider.d.ts +11 -0
- package/dist/commands/provider.js +28 -0
- package/dist/commands/reset.d.ts +9 -0
- package/dist/commands/reset.js +13 -0
- package/dist/commands/skills.d.ts +11 -0
- package/dist/commands/skills.js +106 -0
- package/dist/commands/system.d.ts +11 -0
- package/dist/commands/system.js +20 -0
- package/dist/commands/theme.d.ts +11 -0
- package/dist/commands/theme.js +22 -0
- package/dist/commands/tools.d.ts +7 -0
- package/dist/commands/tools.js +29 -0
- package/dist/commands/whoami.d.ts +9 -0
- package/dist/commands/whoami.js +51 -0
- package/dist/config/paths.d.ts +4 -0
- package/dist/config/paths.js +59 -0
- package/dist/config/store.d.ts +69 -0
- package/dist/config/store.js +208 -0
- package/dist/config/themes.d.ts +73 -0
- package/dist/config/themes.js +77 -0
- package/dist/config/trust.d.ts +9 -0
- package/dist/config/trust.js +67 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +31 -0
- package/dist/lib/diagnostics.d.ts +41 -0
- package/dist/lib/diagnostics.js +139 -0
- package/dist/lib/http.d.ts +13 -0
- package/dist/lib/http.js +70 -0
- package/dist/lib/markdown.d.ts +1 -0
- package/dist/lib/markdown.js +32 -0
- package/dist/lib/pkg.d.ts +5 -0
- package/dist/lib/pkg.js +27 -0
- package/dist/lib/platform.d.ts +7 -0
- package/dist/lib/platform.js +57 -0
- package/dist/lib/startup.d.ts +6 -0
- package/dist/lib/startup.js +25 -0
- package/dist/lib/uiMode.d.ts +2 -0
- package/dist/lib/uiMode.js +7 -0
- package/dist/plugins/manager.d.ts +23 -0
- package/dist/plugins/manager.js +101 -0
- package/dist/providers/anthropic.d.ts +21 -0
- package/dist/providers/anthropic.js +107 -0
- package/dist/providers/custom.d.ts +24 -0
- package/dist/providers/custom.js +25 -0
- package/dist/providers/deepseek.d.ts +24 -0
- package/dist/providers/deepseek.js +15 -0
- package/dist/providers/google.d.ts +24 -0
- package/dist/providers/google.js +19 -0
- package/dist/providers/groq.d.ts +24 -0
- package/dist/providers/groq.js +20 -0
- package/dist/providers/index.d.ts +201 -0
- package/dist/providers/index.js +37 -0
- package/dist/providers/mistral.d.ts +24 -0
- package/dist/providers/mistral.js +19 -0
- package/dist/providers/moonshot.d.ts +24 -0
- package/dist/providers/moonshot.js +22 -0
- package/dist/providers/ollama.d.ts +24 -0
- package/dist/providers/ollama.js +22 -0
- package/dist/providers/openai.d.ts +24 -0
- package/dist/providers/openai.js +25 -0
- package/dist/providers/openaiCompatible.d.ts +17 -0
- package/dist/providers/openaiCompatible.js +110 -0
- package/dist/providers/openrouter.d.ts +24 -0
- package/dist/providers/openrouter.js +26 -0
- package/dist/providers/qwen.d.ts +24 -0
- package/dist/providers/qwen.js +23 -0
- package/dist/providers/xai.d.ts +24 -0
- package/dist/providers/xai.js +18 -0
- package/dist/providers/zhipu.d.ts +24 -0
- package/dist/providers/zhipu.js +23 -0
- package/dist/session/log.d.ts +29 -0
- package/dist/session/log.js +135 -0
- package/dist/skills/manager.d.ts +18 -0
- package/dist/skills/manager.js +201 -0
- package/dist/skills/parser.d.ts +18 -0
- package/dist/skills/parser.js +65 -0
- package/dist/types.d.ts +146 -0
- package/dist/types.js +1 -0
- package/dist/ui/Banner.d.ts +5 -0
- package/dist/ui/Banner.js +65 -0
- package/dist/ui/Message.d.ts +7 -0
- package/dist/ui/Message.js +13 -0
- package/dist/ui/Prompt.d.ts +10 -0
- package/dist/ui/Prompt.js +38 -0
- package/dist/ui/Spinner.d.ts +5 -0
- package/dist/ui/Spinner.js +33 -0
- package/dist/ui/StatusBar.d.ts +10 -0
- package/dist/ui/StatusBar.js +19 -0
- package/dist/ui/TrustGate.d.ts +5 -0
- package/dist/ui/TrustGate.js +26 -0
- package/package.json +96 -4
- package/src/agent/guards.ts +74 -0
- package/src/agent/limits.ts +71 -0
- package/src/agent/protocol.ts +126 -0
- package/src/agent/tools.ts +377 -0
- package/src/app.ts +476 -0
- package/src/cli.ts +308 -0
- package/src/commands/about.ts +16 -0
- package/src/commands/agent.ts +18 -0
- package/src/commands/clear.ts +9 -0
- package/src/commands/config.ts +24 -0
- package/src/commands/cost.ts +13 -0
- package/src/commands/cwd.ts +7 -0
- package/src/commands/exit.ts +8 -0
- package/src/commands/help.ts +18 -0
- package/src/commands/index.ts +58 -0
- package/src/commands/login.ts +61 -0
- package/src/commands/logout.ts +53 -0
- package/src/commands/model.ts +30 -0
- package/src/commands/plugin.ts +74 -0
- package/src/commands/provider.ts +28 -0
- package/src/commands/reset.ts +13 -0
- package/src/commands/skills.ts +106 -0
- package/src/commands/system.ts +20 -0
- package/src/commands/theme.ts +22 -0
- package/src/commands/tools.ts +29 -0
- package/src/commands/whoami.ts +51 -0
- package/src/config/paths.ts +59 -0
- package/src/config/store.ts +240 -0
- package/src/config/themes.ts +77 -0
- package/src/config/trust.ts +67 -0
- package/src/index.ts +31 -0
- package/src/lib/diagnostics.ts +139 -0
- package/src/lib/http.ts +70 -0
- package/src/lib/markdown.ts +32 -0
- package/src/lib/pkg.ts +35 -0
- package/src/lib/platform.ts +57 -0
- package/src/lib/startup.ts +25 -0
- package/src/lib/uiMode.ts +7 -0
- package/src/plugins/manager.ts +101 -0
- package/src/providers/anthropic.ts +107 -0
- package/src/providers/custom.ts +25 -0
- package/src/providers/deepseek.ts +15 -0
- package/src/providers/google.ts +19 -0
- package/src/providers/groq.ts +20 -0
- package/src/providers/index.ts +37 -0
- package/src/providers/mistral.ts +19 -0
- package/src/providers/moonshot.ts +22 -0
- package/src/providers/ollama.ts +22 -0
- package/src/providers/openai.ts +25 -0
- package/src/providers/openaiCompatible.ts +110 -0
- package/src/providers/openrouter.ts +26 -0
- package/src/providers/qwen.ts +23 -0
- package/src/providers/xai.ts +18 -0
- package/src/providers/zhipu.ts +23 -0
- package/src/session/log.ts +135 -0
- package/src/skills/manager.ts +201 -0
- package/src/skills/parser.ts +65 -0
- package/src/types.ts +143 -0
- package/src/ui/Banner.ts +68 -0
- package/src/ui/Message.ts +13 -0
- package/src/ui/Prompt.ts +38 -0
- package/src/ui/Spinner.ts +33 -0
- package/src/ui/StatusBar.ts +19 -0
- package/src/ui/TrustGate.ts +26 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill manager.
|
|
3
|
+
*
|
|
4
|
+
* Skills follow Anthropic's "Agent Skills" convention: a folder containing
|
|
5
|
+
* a SKILL.md file plus optional resource files. FastCode supports three
|
|
6
|
+
* install sources:
|
|
7
|
+
*
|
|
8
|
+
* 1. skills.sh / skillshub.wtf API (default registry)
|
|
9
|
+
* `/skills install pdf` → resolves via the registry
|
|
10
|
+
* `/skills install owner/repo/skill` → fetches raw SKILL.md
|
|
11
|
+
* 2. Direct https URLs ending in SKILL.md.
|
|
12
|
+
* 3. Local paths (just symlinks into ~/.fastcode/skills/).
|
|
13
|
+
*
|
|
14
|
+
* Installed skills can be enabled per-session and are then stitched into
|
|
15
|
+
* the system prompt at chat time, exactly like Claude does internally.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, rmSync, mkdirSync, } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { getPaths } from "../config/paths.js";
|
|
20
|
+
import { fetchJson } from "../lib/http.js";
|
|
21
|
+
import { parseSkill } from "./parser.js";
|
|
22
|
+
const DEFAULT_REGISTRY = "https://skillshub.wtf";
|
|
23
|
+
/** List skills currently installed under ~/.fastcode/skills. */
|
|
24
|
+
export function listInstalledSkills() {
|
|
25
|
+
const { skills } = getPaths();
|
|
26
|
+
if (!existsSync(skills))
|
|
27
|
+
return [];
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const dir of readdirSync(skills, { withFileTypes: true })) {
|
|
30
|
+
if (!dir.isDirectory())
|
|
31
|
+
continue;
|
|
32
|
+
const folder = join(skills, dir.name);
|
|
33
|
+
const md = join(folder, "SKILL.md");
|
|
34
|
+
if (!existsSync(md))
|
|
35
|
+
continue;
|
|
36
|
+
const raw = readFileSync(md, "utf8");
|
|
37
|
+
const parsed = parseSkill(raw, dir.name);
|
|
38
|
+
const sourcePath = join(folder, ".fastcode-source");
|
|
39
|
+
const source = existsSync(sourcePath)
|
|
40
|
+
? readFileSync(sourcePath, "utf8").trim()
|
|
41
|
+
: "local";
|
|
42
|
+
out.push({
|
|
43
|
+
name: parsed.name || dir.name,
|
|
44
|
+
description: parsed.description,
|
|
45
|
+
source,
|
|
46
|
+
path: folder,
|
|
47
|
+
body: parsed.body,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
51
|
+
}
|
|
52
|
+
/** Look up a skill by name (matches folder name OR frontmatter name). */
|
|
53
|
+
export function getSkill(name) {
|
|
54
|
+
return listInstalledSkills().find((s) => s.name === name || basename(s.path) === name);
|
|
55
|
+
}
|
|
56
|
+
/** Install a skill. Returns the parsed manifest on success. */
|
|
57
|
+
export async function installSkill(spec, opts: any = {}) {
|
|
58
|
+
const { skills } = getPaths();
|
|
59
|
+
const registry = (opts.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, "");
|
|
60
|
+
let parsed;
|
|
61
|
+
let source;
|
|
62
|
+
let folderName;
|
|
63
|
+
if (spec.startsWith("http://") || spec.startsWith("https://")) {
|
|
64
|
+
const body = await fetchText(spec);
|
|
65
|
+
parsed = parseSkill(body, slugify(spec));
|
|
66
|
+
source = spec;
|
|
67
|
+
folderName = slugify(parsed.name || slugify(spec));
|
|
68
|
+
}
|
|
69
|
+
else if (looksLikeRepoPath(spec)) {
|
|
70
|
+
// owner/repo or owner/repo/skill — fetch via registry's raw endpoint.
|
|
71
|
+
const url = `${registry}/${spec}?format=md`;
|
|
72
|
+
const body = await fetchText(url);
|
|
73
|
+
parsed = parseSkill(body, parts(spec).pop() ?? spec);
|
|
74
|
+
source = url;
|
|
75
|
+
folderName = slugify(parsed.name || parts(spec).pop() || spec);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
// Single-token name → search the registry.
|
|
79
|
+
const hits = await searchRegistry(spec, registry);
|
|
80
|
+
const hit = hits[0];
|
|
81
|
+
if (!hit)
|
|
82
|
+
throw new Error(`No skill found for "${spec}" in ${registry}`);
|
|
83
|
+
const url = hit.fetchUrl ?? `${registry}/${hit.slug}?format=md`;
|
|
84
|
+
const body = await fetchText(url);
|
|
85
|
+
parsed = parseSkill(body, hit.name);
|
|
86
|
+
source = url;
|
|
87
|
+
folderName = slugify(parsed.name || hit.name);
|
|
88
|
+
}
|
|
89
|
+
const targetDir = join(skills, folderName);
|
|
90
|
+
if (existsSync(targetDir) && !opts.force) {
|
|
91
|
+
throw new Error(`Skill "${folderName}" is already installed. Use --force to overwrite.`);
|
|
92
|
+
}
|
|
93
|
+
if (existsSync(targetDir))
|
|
94
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
95
|
+
mkdirSync(targetDir, { recursive: true });
|
|
96
|
+
writeFileSync(join(targetDir, "SKILL.md"), reassemble(parsed));
|
|
97
|
+
writeFileSync(join(targetDir, ".fastcode-source"), source);
|
|
98
|
+
return {
|
|
99
|
+
name: parsed.name || folderName,
|
|
100
|
+
description: parsed.description,
|
|
101
|
+
source,
|
|
102
|
+
path: targetDir,
|
|
103
|
+
body: parsed.body,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Remove an installed skill by name or folder. */
|
|
107
|
+
export function uninstallSkill(name) {
|
|
108
|
+
const { skills } = getPaths();
|
|
109
|
+
const candidates = readdirSync(skills, { withFileTypes: true });
|
|
110
|
+
for (const dir of candidates) {
|
|
111
|
+
if (!dir.isDirectory())
|
|
112
|
+
continue;
|
|
113
|
+
if (dir.name !== name) {
|
|
114
|
+
// Also match by frontmatter name.
|
|
115
|
+
const md = join(skills, dir.name, "SKILL.md");
|
|
116
|
+
if (!existsSync(md))
|
|
117
|
+
continue;
|
|
118
|
+
const parsed = parseSkill(readFileSync(md, "utf8"), dir.name);
|
|
119
|
+
if (parsed.name !== name)
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
rmSync(join(skills, dir.name), { recursive: true, force: true });
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
/** Search the registry for skills matching a query. */
|
|
128
|
+
export async function searchRegistry(query, registry = DEFAULT_REGISTRY) {
|
|
129
|
+
const base = registry.replace(/\/+$/, "");
|
|
130
|
+
try {
|
|
131
|
+
const json = await fetchJson(`${base}/api/v1/skills/search?q=${encodeURIComponent(query)}`);
|
|
132
|
+
return json.data ?? [];
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Compose the system-prompt addendum for a list of enabled skill names. */
|
|
139
|
+
export function buildSkillsPrompt(enabled) {
|
|
140
|
+
if (enabled.length === 0)
|
|
141
|
+
return "";
|
|
142
|
+
const installed = listInstalledSkills();
|
|
143
|
+
const chunks = [];
|
|
144
|
+
for (const name of enabled) {
|
|
145
|
+
const skill = installed.find((s) => s.name === name || basename(s.path) === name);
|
|
146
|
+
if (!skill)
|
|
147
|
+
continue;
|
|
148
|
+
chunks.push(`<skill name="${skill.name}">\n${skill.body.trim()}\n</skill>`);
|
|
149
|
+
}
|
|
150
|
+
if (chunks.length === 0)
|
|
151
|
+
return "";
|
|
152
|
+
return [
|
|
153
|
+
"",
|
|
154
|
+
"## Active skills",
|
|
155
|
+
"The following skills are loaded for this session. Follow their instructions when relevant:",
|
|
156
|
+
"",
|
|
157
|
+
chunks.join("\n\n"),
|
|
158
|
+
].join("\n");
|
|
159
|
+
}
|
|
160
|
+
// ---------- helpers ----------
|
|
161
|
+
async function fetchText(url) {
|
|
162
|
+
const res = await fetch(url);
|
|
163
|
+
if (!res.ok)
|
|
164
|
+
throw new Error(`Fetch ${url} failed: ${res.status} ${res.statusText}`);
|
|
165
|
+
return await res.text();
|
|
166
|
+
}
|
|
167
|
+
function reassemble(parsed) {
|
|
168
|
+
const fmEntries = Object.entries(parsed.frontmatter);
|
|
169
|
+
if (fmEntries.length === 0 && parsed.name && parsed.description) {
|
|
170
|
+
return [
|
|
171
|
+
"---",
|
|
172
|
+
`name: ${parsed.name}`,
|
|
173
|
+
`description: ${parsed.description}`,
|
|
174
|
+
"---",
|
|
175
|
+
"",
|
|
176
|
+
parsed.body.trim(),
|
|
177
|
+
"",
|
|
178
|
+
].join("\n");
|
|
179
|
+
}
|
|
180
|
+
if (fmEntries.length === 0)
|
|
181
|
+
return parsed.body;
|
|
182
|
+
const fmLines = fmEntries.map(([k, v]) => `${k}: ${v}`);
|
|
183
|
+
return ["---", ...fmLines, "---", "", parsed.body.trim(), ""].join("\n");
|
|
184
|
+
}
|
|
185
|
+
function slugify(s) {
|
|
186
|
+
return s
|
|
187
|
+
.toLowerCase()
|
|
188
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
189
|
+
.replace(/^-+|-+$/g, "")
|
|
190
|
+
.slice(0, 64) || "skill";
|
|
191
|
+
}
|
|
192
|
+
function looksLikeRepoPath(spec) {
|
|
193
|
+
return /^[\w-]+\/[\w.-]+(\/[\w.-]+)?$/.test(spec) && !spec.startsWith("/");
|
|
194
|
+
}
|
|
195
|
+
function parts(spec) {
|
|
196
|
+
return spec.split("/").filter(Boolean);
|
|
197
|
+
}
|
|
198
|
+
function basename(p) {
|
|
199
|
+
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
|
|
200
|
+
return i === -1 ? p : p.slice(i + 1);
|
|
201
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SKILL.md parser. Skills follow Anthropic's Agent Skills convention:
|
|
3
|
+
*
|
|
4
|
+
* ---
|
|
5
|
+
* name: my-skill
|
|
6
|
+
* description: ...
|
|
7
|
+
* ---
|
|
8
|
+
* <markdown body>
|
|
9
|
+
*
|
|
10
|
+
* We accept both `---` and `+++` fences and tolerate missing frontmatter
|
|
11
|
+
* (treating the first H1 + paragraph as a fallback name/description).
|
|
12
|
+
*/
|
|
13
|
+
export function parseSkill(source, fallbackName) {
|
|
14
|
+
const fmMatch = source.match(/^(?:---|\+\+\+)\s*\n([\s\S]*?)\n(?:---|\+\+\+)\s*\n?/);
|
|
15
|
+
let frontmatter = {};
|
|
16
|
+
let body = source;
|
|
17
|
+
if (fmMatch) {
|
|
18
|
+
frontmatter = parseFrontmatter(fmMatch[1] ?? "");
|
|
19
|
+
body = source.slice(fmMatch[0].length).trimStart();
|
|
20
|
+
}
|
|
21
|
+
const name = (frontmatter["name"] ?? fallbackName).trim();
|
|
22
|
+
const description = (frontmatter["description"] ??
|
|
23
|
+
extractFirstParagraph(body) ??
|
|
24
|
+
"").trim();
|
|
25
|
+
return { name, description, body, frontmatter };
|
|
26
|
+
}
|
|
27
|
+
function parseFrontmatter(yaml) {
|
|
28
|
+
// Tiny YAML-ish parser: only handles `key: value` lines, which is what
|
|
29
|
+
// SKILL.md frontmatter uses in practice. This avoids pulling in a YAML lib.
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const lineRaw of yaml.split("\n")) {
|
|
32
|
+
const line = lineRaw.trim();
|
|
33
|
+
if (!line || line.startsWith("#"))
|
|
34
|
+
continue;
|
|
35
|
+
const colon = line.indexOf(":");
|
|
36
|
+
if (colon === -1)
|
|
37
|
+
continue;
|
|
38
|
+
const key = line.slice(0, colon).trim();
|
|
39
|
+
let value = line.slice(colon + 1).trim();
|
|
40
|
+
if ((value.startsWith("\"") && value.endsWith("\"")) ||
|
|
41
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
42
|
+
value = value.slice(1, -1);
|
|
43
|
+
}
|
|
44
|
+
out[key] = value;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
function extractFirstParagraph(body) {
|
|
49
|
+
const lines = body.split("\n");
|
|
50
|
+
let buf = [];
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
if (line.startsWith("#")) {
|
|
53
|
+
if (buf.length)
|
|
54
|
+
break;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!line.trim()) {
|
|
58
|
+
if (buf.length)
|
|
59
|
+
break;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
buf.push(line.trim());
|
|
63
|
+
}
|
|
64
|
+
return buf.length ? buf.join(" ") : undefined;
|
|
65
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types used across FastCode.
|
|
3
|
+
*
|
|
4
|
+
* The provider abstraction is intentionally narrow — it only exposes
|
|
5
|
+
* what the chat loop needs (streaming completions, model list, auth).
|
|
6
|
+
* Providers can layer richer behavior on top in their own modules.
|
|
7
|
+
*/
|
|
8
|
+
export type Role = "system" | "user" | "assistant";
|
|
9
|
+
export interface ChatMessage {
|
|
10
|
+
role: Role;
|
|
11
|
+
content: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ChatRequest {
|
|
14
|
+
model: string;
|
|
15
|
+
messages: ChatMessage[];
|
|
16
|
+
/** Optional system prompt. Some providers fold this into messages. */
|
|
17
|
+
system?: string;
|
|
18
|
+
/** Sampling temperature. */
|
|
19
|
+
temperature?: number;
|
|
20
|
+
/** Soft cap for output tokens. */
|
|
21
|
+
maxOutputTokens?: number;
|
|
22
|
+
/** AbortSignal for cancellation (Esc / Ctrl+C). */
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
export interface ChatChunk {
|
|
26
|
+
/** Token text appended to the response. */
|
|
27
|
+
delta: string;
|
|
28
|
+
/** True on the final chunk. */
|
|
29
|
+
done: boolean;
|
|
30
|
+
/** Provider-specific usage info on the final chunk. */
|
|
31
|
+
usage?: {
|
|
32
|
+
inputTokens?: number;
|
|
33
|
+
outputTokens?: number;
|
|
34
|
+
totalTokens?: number;
|
|
35
|
+
/** Approximate cost in USD if known. */
|
|
36
|
+
costUsd?: number;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export type ChatStream = AsyncIterable<ChatChunk>;
|
|
40
|
+
export interface ProviderModel {
|
|
41
|
+
id: string;
|
|
42
|
+
label?: string;
|
|
43
|
+
/** Optional context window in tokens. */
|
|
44
|
+
contextWindow?: number;
|
|
45
|
+
/** Pricing per 1M tokens, used for cost estimation. */
|
|
46
|
+
pricing?: {
|
|
47
|
+
inputUsdPerM?: number;
|
|
48
|
+
outputUsdPerM?: number;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export interface Provider {
|
|
52
|
+
/** Unique id, e.g. "anthropic". */
|
|
53
|
+
id: string;
|
|
54
|
+
/** Human label. */
|
|
55
|
+
label: string;
|
|
56
|
+
/** ENV var(s) that, when set, satisfy auth. */
|
|
57
|
+
authEnv: string[];
|
|
58
|
+
/** Base URL — many providers expose OpenAI-compatible endpoints. */
|
|
59
|
+
baseUrl: string;
|
|
60
|
+
/** Default model id when the user hasn't picked one. */
|
|
61
|
+
defaultModel: string;
|
|
62
|
+
/** Static or dynamic list of supported models. */
|
|
63
|
+
listModels(apiKey: string | undefined): Promise<ProviderModel[]>;
|
|
64
|
+
/** Stream a chat completion. */
|
|
65
|
+
stream(req: ChatRequest, apiKey: string | undefined): ChatStream;
|
|
66
|
+
}
|
|
67
|
+
export interface SlashCommand {
|
|
68
|
+
name: string;
|
|
69
|
+
description: string;
|
|
70
|
+
/** Optional aliases users can also type. */
|
|
71
|
+
aliases?: string[];
|
|
72
|
+
/** Hide from /help (useful for power-user commands). */
|
|
73
|
+
hidden?: boolean;
|
|
74
|
+
/** Execute the command. Return text to render, or undefined for no output. */
|
|
75
|
+
run(ctx: CommandContext): Promise<CommandResult> | CommandResult;
|
|
76
|
+
}
|
|
77
|
+
export interface CommandResult {
|
|
78
|
+
/** Text rendered as a system message in the transcript. */
|
|
79
|
+
message?: string;
|
|
80
|
+
/** Mark this turn as silent (no echo of the slash itself). */
|
|
81
|
+
silent?: boolean;
|
|
82
|
+
/** Quit the app after this command. */
|
|
83
|
+
quit?: boolean;
|
|
84
|
+
}
|
|
85
|
+
export interface CommandContext {
|
|
86
|
+
args: string[];
|
|
87
|
+
raw: string;
|
|
88
|
+
state: AppStateSnapshot;
|
|
89
|
+
actions: AppActions;
|
|
90
|
+
}
|
|
91
|
+
export interface AppStateSnapshot {
|
|
92
|
+
providerId: string;
|
|
93
|
+
model: string;
|
|
94
|
+
theme: string;
|
|
95
|
+
agentMode: boolean;
|
|
96
|
+
history: ChatMessage[];
|
|
97
|
+
systemPrompt: string;
|
|
98
|
+
totalCostUsd: number;
|
|
99
|
+
totalInputTokens: number;
|
|
100
|
+
totalOutputTokens: number;
|
|
101
|
+
enabledSkills: string[];
|
|
102
|
+
enabledPlugins: string[];
|
|
103
|
+
skillsRegistry: string;
|
|
104
|
+
}
|
|
105
|
+
export interface AppActions {
|
|
106
|
+
setProvider(id: string): void;
|
|
107
|
+
setModel(id: string): void;
|
|
108
|
+
setTheme(id: string): void;
|
|
109
|
+
setAgentMode(enabled: boolean): void;
|
|
110
|
+
setSystemPrompt(prompt: string): void;
|
|
111
|
+
setSkillsRegistry(url: string): void;
|
|
112
|
+
enableSkill(name: string): void;
|
|
113
|
+
disableSkill(name: string): void;
|
|
114
|
+
enablePlugin(name: string): void;
|
|
115
|
+
disablePlugin(name: string): void;
|
|
116
|
+
clearHistory(): void;
|
|
117
|
+
pushSystemMessage(text: string): void;
|
|
118
|
+
/** Begin the guided API-key flow (choose provider → model → key). */
|
|
119
|
+
startLogin(options: { provider?: string; model?: string }): void;
|
|
120
|
+
}
|
|
121
|
+
export interface SkillManifest {
|
|
122
|
+
name: string;
|
|
123
|
+
description: string;
|
|
124
|
+
/** Where the skill came from (e.g. "skills.sh", "anthropics/skills"). */
|
|
125
|
+
source: string;
|
|
126
|
+
/** Local on-disk path to the skill folder. */
|
|
127
|
+
path: string;
|
|
128
|
+
/** Raw SKILL.md body (the prompt content). */
|
|
129
|
+
body: string;
|
|
130
|
+
}
|
|
131
|
+
export interface Theme {
|
|
132
|
+
id: string;
|
|
133
|
+
label: string;
|
|
134
|
+
banner: [string, string, string];
|
|
135
|
+
user: string;
|
|
136
|
+
assistant: string;
|
|
137
|
+
system: string;
|
|
138
|
+
border: string;
|
|
139
|
+
accent: string;
|
|
140
|
+
muted: string;
|
|
141
|
+
error: string;
|
|
142
|
+
success: string;
|
|
143
|
+
}
|
package/src/ui/Banner.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Animated FastCode banner shown at startup.
|
|
4
|
+
*
|
|
5
|
+
* We render the wordmark with ink-big-text + a gradient that slowly cycles
|
|
6
|
+
* through the theme's banner triple. The animation runs for ~1s and then
|
|
7
|
+
* settles on a stable gradient so we don't waste GPU cycles forever.
|
|
8
|
+
*
|
|
9
|
+
* The font is chosen to fit the terminal: `block` needs ~74 columns, so on a
|
|
10
|
+
* narrow phone terminal we fall back to progressively smaller fonts (see
|
|
11
|
+
* BANNER_FONTS). If even the smallest font is wider than the terminal, the
|
|
12
|
+
* banner is skipped entirely.
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useState } from "react";
|
|
15
|
+
import { Box, Text, useStdout } from "ink";
|
|
16
|
+
import BigText from "ink-big-text";
|
|
17
|
+
import Gradient from "ink-gradient";
|
|
18
|
+
const FRAMES = 16;
|
|
19
|
+
|
|
20
|
+
// (font, columns) needed to render the literal text "FastCode" with
|
|
21
|
+
// letterSpacing 1 and space:false. Measured with cfonts; largest first.
|
|
22
|
+
const BANNER_FONTS = [
|
|
23
|
+
{ font: "block", columns: 74 },
|
|
24
|
+
{ font: "simple", columns: 56 },
|
|
25
|
+
{ font: "slick", columns: 49 },
|
|
26
|
+
{ font: "shade", columns: 39 },
|
|
27
|
+
{ font: "chrome", columns: 32 },
|
|
28
|
+
{ font: "console", columns: 16 },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** Pick the largest font that fits `columns` (2 columns reserved for padding). */
|
|
32
|
+
function pickBannerFont(columns: number): string | null {
|
|
33
|
+
const usable = columns - 2;
|
|
34
|
+
for (const entry of BANNER_FONTS) {
|
|
35
|
+
if (entry.columns <= usable)
|
|
36
|
+
return entry.font;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const Banner = ({ theme, subtitle, stable = false }) => {
|
|
42
|
+
const [frame, setFrame] = useState(0);
|
|
43
|
+
const { stdout } = useStdout();
|
|
44
|
+
const columns = stdout?.columns ?? process.stdout.columns ?? 80;
|
|
45
|
+
const font = pickBannerFont(columns);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (stable)
|
|
48
|
+
return;
|
|
49
|
+
if (frame >= FRAMES)
|
|
50
|
+
return;
|
|
51
|
+
const t = setTimeout(() => setFrame((f) => f + 1), 60);
|
|
52
|
+
return () => clearTimeout(t);
|
|
53
|
+
}, [frame, stable]);
|
|
54
|
+
if (!font)
|
|
55
|
+
return null;
|
|
56
|
+
const t = stable ? 1 : Math.min(1, frame / FRAMES);
|
|
57
|
+
const colors = rotate(theme.banner, t);
|
|
58
|
+
return (_jsxs(Box, { flexDirection: "column", alignItems: "flex-start", marginBottom: 1, children: [_jsx(Gradient, { colors: colors, children: _jsx(BigText, { text: "FastCode", font: font, space: false }) }), _jsx(Text, { color: theme.muted, children: subtitle ?? "multi-LLM TUI · skills.sh · slash commands · /help to start" })] }));
|
|
59
|
+
};
|
|
60
|
+
/** Cycle the gradient stops by `t ∈ [0,1]` for a subtle shimmer effect. */
|
|
61
|
+
function rotate(stops, t) {
|
|
62
|
+
const [a, b, c] = stops;
|
|
63
|
+
if (t < 0.33)
|
|
64
|
+
return [a, b, c];
|
|
65
|
+
if (t < 0.66)
|
|
66
|
+
return [b, c, a];
|
|
67
|
+
return [c, a, b];
|
|
68
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { renderMarkdown } from "../lib/markdown.js";
|
|
4
|
+
const SIGIL = {
|
|
5
|
+
user: "▎ you",
|
|
6
|
+
assistant: "▍ FastCode",
|
|
7
|
+
system: "✦ system",
|
|
8
|
+
};
|
|
9
|
+
export const MessageBubble = ({ theme, role, content, streaming, markdown, }) => {
|
|
10
|
+
const color = role === "user" ? theme.user : role === "assistant" ? theme.assistant : theme.system;
|
|
11
|
+
const display = markdown ? renderMarkdown(content) : content;
|
|
12
|
+
return (_jsxs(Box, { flexDirection: "column", marginY: 0, children: [_jsxs(Text, { color: color, bold: true, children: [SIGIL[role], streaming ? _jsx(Text, { color: theme.muted, children: " (streaming)" }) : null] }), _jsx(Box, { marginLeft: 2, flexDirection: "column", children: _jsx(Text, { color: color, children: display || (streaming ? "…" : "") }) })] }));
|
|
13
|
+
};
|
package/src/ui/Prompt.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Bottom prompt input, with a left-side caret + slash-command autocomplete.
|
|
4
|
+
*
|
|
5
|
+
* We re-use ink-text-input for low-level keyboard handling but wrap it with
|
|
6
|
+
* a fixed caret indicator + autocomplete preview to mirror the look of
|
|
7
|
+
* Claude Code's prompt.
|
|
8
|
+
*
|
|
9
|
+
* The caret used to blink on a 500 ms setInterval. That timer re-rendered the
|
|
10
|
+
* whole input line twice a second, which on some terminals rewrote the line
|
|
11
|
+
* while the text cursor was being re-positioned and made the caret occasionally
|
|
12
|
+
* jump one column ahead after the first keystroke. A static caret removes the
|
|
13
|
+
* timer-driven redraw; ink-text-input's own cursor still tracks the real caret.
|
|
14
|
+
*/
|
|
15
|
+
import { Box, Text } from "ink";
|
|
16
|
+
import TextInput from "ink-text-input";
|
|
17
|
+
import { COMMANDS } from "../commands/index.js";
|
|
18
|
+
export const Prompt = ({ theme, value, onChange, onSubmit, disabled, placeholder, stable = false, mask, }) => {
|
|
19
|
+
const suggestion = autocomplete(value);
|
|
20
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: disabled ? theme.muted : theme.accent, paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: "\u276F " }), disabled ? (_jsx(Text, { color: theme.muted, children: placeholder ?? "Working… (Esc to cancel)" })) : (_jsx(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit, placeholder: placeholder ?? "Type a message or /help …", showCursor: !stable, mask: mask }))] }), !disabled && suggestion ? (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: theme.muted, children: ["\u21AA ", suggestion] }) })) : null] }));
|
|
21
|
+
};
|
|
22
|
+
function autocomplete(value) {
|
|
23
|
+
if (!value.startsWith("/") || value.includes(" "))
|
|
24
|
+
return undefined;
|
|
25
|
+
const partial = value.slice(1).toLowerCase();
|
|
26
|
+
if (!partial) {
|
|
27
|
+
return COMMANDS.filter((c) => !c.hidden)
|
|
28
|
+
.map((c) => `/${c.name}`)
|
|
29
|
+
.slice(0, 6)
|
|
30
|
+
.join(" ");
|
|
31
|
+
}
|
|
32
|
+
const matches = COMMANDS.filter((c) => !c.hidden &&
|
|
33
|
+
(c.name.startsWith(partial) ||
|
|
34
|
+
(c.aliases ?? []).some((a) => a.startsWith(partial))));
|
|
35
|
+
if (matches.length === 0)
|
|
36
|
+
return undefined;
|
|
37
|
+
return matches.map((c) => `/${c.name} — ${c.description}`).slice(0, 4).join("\n ");
|
|
38
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Custom thinking spinner.
|
|
4
|
+
*
|
|
5
|
+
* We don't use ink-spinner's built-in spinners directly because we want to
|
|
6
|
+
* (1) cycle through a varied set of action verbs ("Thinking…", "Pondering…")
|
|
7
|
+
* and (2) keep the dots animation in sync with the verb rotation.
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useState } from "react";
|
|
10
|
+
import { Text } from "ink";
|
|
11
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
12
|
+
const VERBS = [
|
|
13
|
+
"Thinking",
|
|
14
|
+
"Pondering",
|
|
15
|
+
"Reasoning",
|
|
16
|
+
"Cooking",
|
|
17
|
+
"Crafting",
|
|
18
|
+
"Brewing",
|
|
19
|
+
"Sketching",
|
|
20
|
+
"Charging",
|
|
21
|
+
];
|
|
22
|
+
export const Spinner = ({ theme, label, stable = false }) => {
|
|
23
|
+
const [tick, setTick] = useState(0);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (stable)
|
|
26
|
+
return;
|
|
27
|
+
const id = setInterval(() => setTick((t) => t + 1), 80);
|
|
28
|
+
return () => clearInterval(id);
|
|
29
|
+
}, [stable]);
|
|
30
|
+
const frame = stable ? "⠋" : FRAMES[tick % FRAMES.length];
|
|
31
|
+
const verb = label ?? VERBS[Math.floor(tick / 24) % VERBS.length];
|
|
32
|
+
return (_jsxs(Text, { color: theme.accent, children: [frame, " ", _jsxs(Text, { color: theme.muted, children: [verb, "\u2026"] })] }));
|
|
33
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useStdout } from "ink";
|
|
3
|
+
import { platformLabel } from "../lib/platform.js";
|
|
4
|
+
export const StatusBar = ({ theme, providerId, model, inputTokens, outputTokens, costUsd, enabledSkillCount, hint, }) => {
|
|
5
|
+
const { stdout } = useStdout();
|
|
6
|
+
const columns = stdout?.columns ?? process.stdout.columns ?? 80;
|
|
7
|
+
const compact = columns < 88;
|
|
8
|
+
const cost = costUsd > 0 ? `$${costUsd.toFixed(4)}` : "$0.0000";
|
|
9
|
+
const providerModel = `${providerId}/${model}`;
|
|
10
|
+
const label = compact ? truncate(providerModel, Math.max(18, columns - 28)) : providerModel;
|
|
11
|
+
return (_jsxs(Box, { flexDirection: compact ? "column" : "row", justifyContent: "space-between", borderStyle: "round", borderColor: theme.border, paddingX: 1, children: [_jsxs(Text, { color: theme.muted, children: [_jsx(Text, { color: theme.accent, children: "\u25CF" }), " ", label, _jsx(Text, { color: theme.muted, children: " \u00B7 " }), inputTokens, "\u2191 ", outputTokens, "\u2193 ", cost, !compact ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: " \u00B7 " }), "skills: ", enabledSkillCount, _jsx(Text, { color: theme.muted, children: " \u00B7 " }), platformLabel()] })) : null] }), _jsx(Text, { color: theme.muted, children: compact
|
|
12
|
+
? `${platformLabel()} · skills:${enabledSkillCount} · ${hint ?? "/help · esc · ctrl+c"}`
|
|
13
|
+
: hint ?? "/help · esc cancel · ctrl+c quit" })] }));
|
|
14
|
+
};
|
|
15
|
+
function truncate(value, max) {
|
|
16
|
+
if (value.length <= max)
|
|
17
|
+
return value;
|
|
18
|
+
return `${value.slice(0, Math.max(1, max - 1))}…`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* First-run confirmation for a folder the agent is not trusted with yet.
|
|
4
|
+
*
|
|
5
|
+
* Agent tools can read, write, delete and execute in the workspace, so the user
|
|
6
|
+
* decides before anything loads.
|
|
7
|
+
*/
|
|
8
|
+
import { Box, Text, useInput } from "ink";
|
|
9
|
+
export const TrustGate = ({ theme, cwd, onDecision }) => {
|
|
10
|
+
useInput((input) => {
|
|
11
|
+
const value = String(input ?? "").toLowerCase();
|
|
12
|
+
if (value === "y")
|
|
13
|
+
onDecision(true);
|
|
14
|
+
else if (value === "n" || value === "q")
|
|
15
|
+
onDecision(false);
|
|
16
|
+
});
|
|
17
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, marginTop: 1, children: [
|
|
18
|
+
_jsx(Text, { color: theme.accent, bold: true, children: "Trust this folder?" }),
|
|
19
|
+
_jsx(Text, { color: theme.muted, children: cwd }),
|
|
20
|
+
_jsx(Text, { children: "" }),
|
|
21
|
+
_jsx(Text, { children: "FastCode agent tools can read, write and delete files here, and run shell commands." }),
|
|
22
|
+
_jsx(Text, { children: "Continue only if you trust this project." }),
|
|
23
|
+
_jsx(Text, { children: "" }),
|
|
24
|
+
_jsx(Text, { children: "[y] trust and continue [n] decline (chat only) [q] quit" }),
|
|
25
|
+
] }));
|
|
26
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2023"],
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"outDir": "dist",
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"sourceMap": false,
|
|
11
|
+
"removeComments": false,
|
|
12
|
+
"strict": false,
|
|
13
|
+
"noImplicitAny": false,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"esModuleInterop": true,
|
|
16
|
+
"allowSyntheticDefaultImports": true,
|
|
17
|
+
"forceConsistentCasingInFileNames": true,
|
|
18
|
+
"resolveJsonModule": true,
|
|
19
|
+
"noEmitOnError": false
|
|
20
|
+
},
|
|
21
|
+
"include": ["src/**/*.ts"]
|
|
22
|
+
}
|