rtfct 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/.project/adrs/001-use-bun-typescript.md +52 -0
- package/.project/guardrails.md +65 -0
- package/.project/kanban/backlog.md +7 -0
- package/.project/kanban/done.md +240 -0
- package/.project/kanban/in-progress.md +11 -0
- package/.project/kickstart.md +63 -0
- package/.project/protocol.md +134 -0
- package/.project/specs/requirements.md +152 -0
- package/.project/testing/strategy.md +123 -0
- package/.project/theology.md +125 -0
- package/CLAUDE.md +119 -0
- package/README.md +143 -0
- package/package.json +31 -0
- package/src/args.ts +104 -0
- package/src/commands/add.ts +78 -0
- package/src/commands/init.ts +128 -0
- package/src/commands/praise.ts +19 -0
- package/src/commands/regenerate.ts +122 -0
- package/src/commands/status.ts +163 -0
- package/src/help.ts +52 -0
- package/src/index.ts +102 -0
- package/src/kanban.ts +83 -0
- package/src/manifest.ts +67 -0
- package/src/presets/base.ts +195 -0
- package/src/presets/elixir.ts +118 -0
- package/src/presets/github.ts +194 -0
- package/src/presets/index.ts +154 -0
- package/src/presets/typescript.ts +589 -0
- package/src/presets/zig.ts +494 -0
- package/tests/integration/add.test.ts +104 -0
- package/tests/integration/init.test.ts +197 -0
- package/tests/integration/praise.test.ts +36 -0
- package/tests/integration/regenerate.test.ts +154 -0
- package/tests/integration/status.test.ts +165 -0
- package/tests/unit/args.test.ts +144 -0
- package/tests/unit/kanban.test.ts +162 -0
- package/tests/unit/manifest.test.ts +155 -0
- package/tests/unit/presets.test.ts +295 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Preset Resolution — Fetching Codices from the Cloud
|
|
3
|
+
*
|
|
4
|
+
* Enables downloading presets from GitHub repositories.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Preset, PresetFile } from "./index";
|
|
8
|
+
|
|
9
|
+
export interface GitHubPresetRef {
|
|
10
|
+
owner: string;
|
|
11
|
+
repo: string;
|
|
12
|
+
ref: string; // branch, tag, or commit (default: main)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse a GitHub preset reference string.
|
|
17
|
+
* Formats:
|
|
18
|
+
* - "owner/repo" -> uses "main" branch
|
|
19
|
+
* - "owner/repo@branch" -> uses specified branch/tag/commit
|
|
20
|
+
*/
|
|
21
|
+
export const parseGitHubRef = (name: string): GitHubPresetRef | null => {
|
|
22
|
+
// Check for @ symbol for branch specification
|
|
23
|
+
const atIndex = name.indexOf("@");
|
|
24
|
+
|
|
25
|
+
let ownerRepo: string;
|
|
26
|
+
let ref = "main";
|
|
27
|
+
|
|
28
|
+
if (atIndex !== -1) {
|
|
29
|
+
ownerRepo = name.slice(0, atIndex);
|
|
30
|
+
ref = name.slice(atIndex + 1);
|
|
31
|
+
if (!ref) {
|
|
32
|
+
return null; // Invalid: "owner/repo@" with no branch
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
ownerRepo = name;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const parts = ownerRepo.split("/");
|
|
39
|
+
if (parts.length !== 2) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const [owner, repo] = parts;
|
|
44
|
+
if (!owner || !repo) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { owner, repo, ref };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fetch a file from GitHub raw content.
|
|
53
|
+
*/
|
|
54
|
+
const fetchGitHubFile = async (
|
|
55
|
+
ref: GitHubPresetRef,
|
|
56
|
+
path: string
|
|
57
|
+
): Promise<string | null> => {
|
|
58
|
+
const url = `https://raw.githubusercontent.com/${ref.owner}/${ref.repo}/${ref.ref}/${path}`;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(url);
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return await response.text();
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Fetch the directory listing from GitHub API.
|
|
73
|
+
*/
|
|
74
|
+
const fetchGitHubDirectory = async (
|
|
75
|
+
ref: GitHubPresetRef,
|
|
76
|
+
path: string = ""
|
|
77
|
+
): Promise<string[]> => {
|
|
78
|
+
const url = `https://api.github.com/repos/${ref.owner}/${ref.repo}/contents/${path}?ref=${ref.ref}`;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const response = await fetch(url, {
|
|
82
|
+
headers: {
|
|
83
|
+
Accept: "application/vnd.github.v3+json",
|
|
84
|
+
"User-Agent": "rtfct-cli",
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const data = (await response.json()) as Array<{
|
|
93
|
+
type: string;
|
|
94
|
+
path: string;
|
|
95
|
+
name: string;
|
|
96
|
+
}>;
|
|
97
|
+
|
|
98
|
+
const files: string[] = [];
|
|
99
|
+
|
|
100
|
+
for (const item of data) {
|
|
101
|
+
if (item.type === "file") {
|
|
102
|
+
files.push(item.path);
|
|
103
|
+
} else if (item.type === "dir") {
|
|
104
|
+
// Recursively fetch directory contents
|
|
105
|
+
const subFiles = await fetchGitHubDirectory(ref, item.path);
|
|
106
|
+
files.push(...subFiles);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return files;
|
|
111
|
+
} catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Resolve a GitHub preset reference to a Preset object.
|
|
118
|
+
*/
|
|
119
|
+
export const resolveGitHubPreset = async (
|
|
120
|
+
name: string
|
|
121
|
+
): Promise<{ success: true; preset: Preset } | { success: false; error: string }> => {
|
|
122
|
+
const ref = parseGitHubRef(name);
|
|
123
|
+
|
|
124
|
+
if (!ref) {
|
|
125
|
+
return {
|
|
126
|
+
success: false,
|
|
127
|
+
error: `Invalid GitHub preset format: ${name}. Use "owner/repo" or "owner/repo@branch".`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// First, fetch the manifest.json
|
|
132
|
+
const manifestContent = await fetchGitHubFile(ref, "manifest.json");
|
|
133
|
+
|
|
134
|
+
if (!manifestContent) {
|
|
135
|
+
return {
|
|
136
|
+
success: false,
|
|
137
|
+
error: `Could not fetch manifest.json from ${name}. Ensure the repository exists and contains a valid preset.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let manifest: Preset["manifest"];
|
|
142
|
+
try {
|
|
143
|
+
manifest = JSON.parse(manifestContent);
|
|
144
|
+
} catch {
|
|
145
|
+
return {
|
|
146
|
+
success: false,
|
|
147
|
+
error: `Invalid manifest.json in ${name}. The file must be valid JSON.`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Validate manifest has required fields
|
|
152
|
+
if (!manifest.name || !manifest.version || !manifest.generated_paths) {
|
|
153
|
+
return {
|
|
154
|
+
success: false,
|
|
155
|
+
error: `Invalid manifest.json in ${name}. Missing required fields (name, version, generated_paths).`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Fetch all files in the repository (excluding manifest.json)
|
|
160
|
+
const allFiles = await fetchGitHubDirectory(ref);
|
|
161
|
+
|
|
162
|
+
if (allFiles.length === 0) {
|
|
163
|
+
return {
|
|
164
|
+
success: false,
|
|
165
|
+
error: `Could not fetch file list from ${name}. Check if the repository is accessible.`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Filter out manifest.json and fetch content for each file
|
|
170
|
+
const presetFiles: PresetFile[] = [];
|
|
171
|
+
|
|
172
|
+
for (const filePath of allFiles) {
|
|
173
|
+
// Skip manifest.json and hidden files
|
|
174
|
+
if (filePath === "manifest.json" || filePath.startsWith(".")) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const content = await fetchGitHubFile(ref, filePath);
|
|
179
|
+
if (content !== null) {
|
|
180
|
+
presetFiles.push({
|
|
181
|
+
path: filePath,
|
|
182
|
+
content,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const preset: Preset = {
|
|
188
|
+
name: manifest.name,
|
|
189
|
+
manifest,
|
|
190
|
+
files: presetFiles,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return { success: true, preset };
|
|
194
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Preset System — Codex Resolution and Installation
|
|
3
|
+
*
|
|
4
|
+
* Resolves preset names to Codex content and writes them to projects.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
|
|
10
|
+
import { BASE_PRESET } from "./base";
|
|
11
|
+
import { ZIG_PRESET } from "./zig";
|
|
12
|
+
import { TYPESCRIPT_PRESET } from "./typescript";
|
|
13
|
+
import { ELIXIR_PRESET } from "./elixir";
|
|
14
|
+
import { parseGitHubRef, resolveGitHubPreset } from "./github";
|
|
15
|
+
|
|
16
|
+
export interface PresetFile {
|
|
17
|
+
path: string;
|
|
18
|
+
content: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Preset {
|
|
22
|
+
name: string;
|
|
23
|
+
manifest: {
|
|
24
|
+
name: string;
|
|
25
|
+
version: string;
|
|
26
|
+
description: string;
|
|
27
|
+
depends?: string[];
|
|
28
|
+
generated_paths: string[];
|
|
29
|
+
};
|
|
30
|
+
files: PresetFile[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ResolveResult =
|
|
34
|
+
| { success: true; preset: Preset }
|
|
35
|
+
| { success: false; error: string };
|
|
36
|
+
|
|
37
|
+
export type AsyncResolveResult = Promise<ResolveResult>;
|
|
38
|
+
|
|
39
|
+
type BuiltInPresetName = "base" | "zig" | "typescript" | "elixir";
|
|
40
|
+
|
|
41
|
+
const BUILT_IN_PRESETS: Record<BuiltInPresetName, Preset> = {
|
|
42
|
+
base: BASE_PRESET,
|
|
43
|
+
zig: ZIG_PRESET,
|
|
44
|
+
typescript: TYPESCRIPT_PRESET,
|
|
45
|
+
elixir: ELIXIR_PRESET,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Re-export base preset for direct use by init command
|
|
49
|
+
export { BASE_PRESET } from "./base";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve a preset name to a Preset object (synchronous, built-in only).
|
|
53
|
+
*/
|
|
54
|
+
export const resolvePresetSync = (name: string): ResolveResult => {
|
|
55
|
+
const lowerName = name.toLowerCase();
|
|
56
|
+
|
|
57
|
+
if (lowerName in BUILT_IN_PRESETS) {
|
|
58
|
+
return {
|
|
59
|
+
success: true,
|
|
60
|
+
preset: BUILT_IN_PRESETS[lowerName as BuiltInPresetName],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
success: false,
|
|
66
|
+
error: `Unknown built-in preset: ${name}. Available: base, zig, typescript, elixir`,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve a preset name to a Preset object.
|
|
72
|
+
* Supports built-in presets, GitHub presets (owner/repo), and local presets.
|
|
73
|
+
*/
|
|
74
|
+
export const resolvePreset = async (name: string): AsyncResolveResult => {
|
|
75
|
+
const lowerName = name.toLowerCase();
|
|
76
|
+
|
|
77
|
+
// Check built-in presets first
|
|
78
|
+
if (lowerName in BUILT_IN_PRESETS) {
|
|
79
|
+
return {
|
|
80
|
+
success: true,
|
|
81
|
+
preset: BUILT_IN_PRESETS[lowerName as BuiltInPresetName],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check local paths (not yet supported)
|
|
86
|
+
if (name.startsWith("./") || name.startsWith("/")) {
|
|
87
|
+
return {
|
|
88
|
+
success: false,
|
|
89
|
+
error: `Local presets not yet supported: ${name}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Check for GitHub preset format (owner/repo or owner/repo@branch)
|
|
94
|
+
if (name.includes("/")) {
|
|
95
|
+
const ref = parseGitHubRef(name);
|
|
96
|
+
if (ref) {
|
|
97
|
+
return await resolveGitHubPreset(name);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
success: false,
|
|
101
|
+
error: `Invalid GitHub preset format: ${name}. Use "owner/repo" or "owner/repo@branch".`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
success: false,
|
|
107
|
+
error: `Unknown preset: ${name}. Available: base, zig, typescript, elixir, or use owner/repo for GitHub presets.`,
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Write a preset to a project's .project/presets/ directory.
|
|
113
|
+
*/
|
|
114
|
+
export const writePreset = async (
|
|
115
|
+
projectDir: string,
|
|
116
|
+
preset: Preset
|
|
117
|
+
): Promise<void> => {
|
|
118
|
+
const presetDir = join(projectDir, ".project", "presets", preset.name);
|
|
119
|
+
|
|
120
|
+
// Create the preset directory
|
|
121
|
+
await mkdir(presetDir, { recursive: true });
|
|
122
|
+
|
|
123
|
+
// Write the manifest
|
|
124
|
+
await writeFile(
|
|
125
|
+
join(presetDir, "manifest.json"),
|
|
126
|
+
JSON.stringify(preset.manifest, null, 2)
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Write all preset files
|
|
130
|
+
for (const file of preset.files) {
|
|
131
|
+
const filePath = join(presetDir, file.path);
|
|
132
|
+
const fileDir = join(filePath, "..");
|
|
133
|
+
await mkdir(fileDir, { recursive: true });
|
|
134
|
+
await writeFile(filePath, file.content);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check if a preset is already installed in a project.
|
|
140
|
+
*/
|
|
141
|
+
export const isPresetInstalled = async (
|
|
142
|
+
projectDir: string,
|
|
143
|
+
presetName: string
|
|
144
|
+
): Promise<boolean> => {
|
|
145
|
+
const presetDir = join(projectDir, ".project", "presets", presetName);
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const { stat } = await import("fs/promises");
|
|
149
|
+
const stats = await stat(presetDir);
|
|
150
|
+
return stats.isDirectory();
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
};
|