@cliperhq/cliper 1.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/README.md +266 -0
- package/dist/commands/analyze.d.ts +6 -0
- package/dist/commands/analyze.d.ts.map +1 -0
- package/dist/commands/analyze.js +216 -0
- package/dist/commands/export.d.ts +6 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +64 -0
- package/dist/commands/init.d.ts +7 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +173 -0
- package/dist/commands/scope.d.ts +2 -0
- package/dist/commands/scope.d.ts.map +1 -0
- package/dist/commands/scope.js +124 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +100 -0
- package/dist/commands/sync.d.ts +6 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +83 -0
- package/dist/context/builder.d.ts +19 -0
- package/dist/context/builder.d.ts.map +1 -0
- package/dist/context/builder.js +143 -0
- package/dist/gaps/detector.d.ts +10 -0
- package/dist/gaps/detector.d.ts.map +1 -0
- package/dist/gaps/detector.js +139 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/resolver/urlFetcher.d.ts +9 -0
- package/dist/resolver/urlFetcher.d.ts.map +1 -0
- package/dist/resolver/urlFetcher.js +134 -0
- package/dist/scanner/dependencies.d.ts +14 -0
- package/dist/scanner/dependencies.d.ts.map +1 -0
- package/dist/scanner/dependencies.js +199 -0
- package/dist/scanner/fileContent.d.ts +8 -0
- package/dist/scanner/fileContent.d.ts.map +1 -0
- package/dist/scanner/fileContent.js +133 -0
- package/dist/scanner/fileTree.d.ts +2 -0
- package/dist/scanner/fileTree.d.ts.map +1 -0
- package/dist/scanner/fileTree.js +152 -0
- package/dist/scanner/gitContext.d.ts +19 -0
- package/dist/scanner/gitContext.d.ts.map +1 -0
- package/dist/scanner/gitContext.js +60 -0
- package/dist/scope/autoScope.d.ts +2 -0
- package/dist/scope/autoScope.d.ts.map +1 -0
- package/dist/scope/autoScope.js +226 -0
- package/dist/scope/config.d.ts +10 -0
- package/dist/scope/config.d.ts.map +1 -0
- package/dist/scope/config.js +71 -0
- package/index.js +2 -0
- package/package.json +37 -0
- package/src/commands/analyze.ts +201 -0
- package/src/commands/export.ts +33 -0
- package/src/commands/init.ts +174 -0
- package/src/commands/scope.ts +77 -0
- package/src/commands/status.ts +67 -0
- package/src/commands/sync.ts +51 -0
- package/src/context/builder.ts +178 -0
- package/src/gaps/detector.ts +131 -0
- package/src/index.ts +54 -0
- package/src/resolver/urlFetcher.ts +119 -0
- package/src/scanner/dependencies.ts +196 -0
- package/src/scanner/fileContent.ts +121 -0
- package/src/scanner/fileTree.ts +149 -0
- package/src/scanner/gitContext.ts +74 -0
- package/src/scope/autoScope.ts +182 -0
- package/src/scope/config.ts +39 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import ora from "ora";
|
|
5
|
+
import { getCliperDir } from "../scope/config";
|
|
6
|
+
import { initCommand } from "./init";
|
|
7
|
+
|
|
8
|
+
interface SyncOptions {
|
|
9
|
+
watch?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function syncCommand(options: SyncOptions): Promise<void> {
|
|
13
|
+
const projectRoot = process.cwd();
|
|
14
|
+
const contextPath = path.join(getCliperDir(projectRoot), "context.md");
|
|
15
|
+
|
|
16
|
+
if (!fs.existsSync(contextPath)) {
|
|
17
|
+
console.log(chalk.yellow("\n No context doc found. Run cliper init first.\n"));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (options.watch) {
|
|
22
|
+
console.log(chalk.cyan("\n Watching for git changes... (Ctrl+C to stop)\n"));
|
|
23
|
+
let lastHash = "";
|
|
24
|
+
|
|
25
|
+
const check = async () => {
|
|
26
|
+
try {
|
|
27
|
+
const { default: simpleGit } = await import("simple-git");
|
|
28
|
+
const git = simpleGit(projectRoot);
|
|
29
|
+
const log = await git.log({ maxCount: 1 });
|
|
30
|
+
const currentHash = log.latest?.hash ?? "";
|
|
31
|
+
|
|
32
|
+
if (currentHash && currentHash !== lastHash) {
|
|
33
|
+
if (lastHash !== "") {
|
|
34
|
+
console.log(chalk.yellow(`\n New commit detected: ${currentHash.slice(0, 7)} — refreshing context...\n`));
|
|
35
|
+
await initCommand({ path: projectRoot });
|
|
36
|
+
}
|
|
37
|
+
lastHash = currentHash;
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// silently skip
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
await check();
|
|
45
|
+
setInterval(check, 10000); // Check every 10 seconds
|
|
46
|
+
} else {
|
|
47
|
+
const spinner = ora("Syncing context document...").start();
|
|
48
|
+
spinner.stop();
|
|
49
|
+
await initCommand({ path: projectRoot });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { FileContent } from "../scanner/fileContent";
|
|
2
|
+
import { GitContext } from "../scanner/gitContext";
|
|
3
|
+
import { ResolvedReference } from "../resolver/urlFetcher";
|
|
4
|
+
import { Gap } from "../gaps/detector";
|
|
5
|
+
|
|
6
|
+
export interface ContextDocOptions {
|
|
7
|
+
projectRoot: string;
|
|
8
|
+
projectName: string;
|
|
9
|
+
activeScope: string[];
|
|
10
|
+
watchedScope: string[];
|
|
11
|
+
fileTree: string;
|
|
12
|
+
files: FileContent[];
|
|
13
|
+
gitContext: GitContext;
|
|
14
|
+
references: ResolvedReference[];
|
|
15
|
+
gaps: Gap[];
|
|
16
|
+
generatedAt: string;
|
|
17
|
+
dependencyMap: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DIVIDER = "━".repeat(50);
|
|
21
|
+
|
|
22
|
+
function formatGitContext(git: GitContext): string {
|
|
23
|
+
if (!git.isGitRepo) return "Not a git repository.";
|
|
24
|
+
|
|
25
|
+
const lines: string[] = [];
|
|
26
|
+
lines.push(`Branch: ${git.branch}`);
|
|
27
|
+
|
|
28
|
+
if (git.lastCommit) {
|
|
29
|
+
lines.push(`Last commit: ${git.lastCommit.hash} — ${git.lastCommit.message} (${git.lastCommit.timeAgo})`);
|
|
30
|
+
lines.push(`Author: ${git.lastCommit.author}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (git.uncommittedChanges.length > 0) {
|
|
34
|
+
lines.push(`\nUncommitted changes (${git.uncommittedChanges.length} files):`);
|
|
35
|
+
for (const f of git.uncommittedChanges.slice(0, 10)) {
|
|
36
|
+
lines.push(` - ${f}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (git.recentCommits.length > 0) {
|
|
41
|
+
lines.push("\nRecent commits:");
|
|
42
|
+
for (const c of git.recentCommits) {
|
|
43
|
+
lines.push(` ${c.hash} — ${c.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return lines.join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatFileContents(files: FileContent[]): string {
|
|
51
|
+
if (files.length === 0) return "No files in scope.";
|
|
52
|
+
|
|
53
|
+
return files
|
|
54
|
+
.map((f) => {
|
|
55
|
+
const ext = f.relativePath.split(".").pop() ?? "";
|
|
56
|
+
const truncatedNote = f.truncated ? "\n[... truncated ...]" : "";
|
|
57
|
+
return `### ${f.relativePath}\n\`\`\`${ext}\n${f.content}${truncatedNote}\n\`\`\``;
|
|
58
|
+
})
|
|
59
|
+
.join("\n\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatReferences(refs: ResolvedReference[]): string {
|
|
63
|
+
const fetched = refs.filter((r) => r.status === "fetched");
|
|
64
|
+
const failed = refs.filter((r) => r.status === "failed");
|
|
65
|
+
|
|
66
|
+
const lines: string[] = [];
|
|
67
|
+
|
|
68
|
+
if (fetched.length === 0 && failed.length === 0) {
|
|
69
|
+
return "No external references found in markdown files.";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const ref of fetched) {
|
|
73
|
+
lines.push(`#### ${ref.url}`);
|
|
74
|
+
lines.push(`Source: ${ref.source}`);
|
|
75
|
+
lines.push(`\n${ref.content}\n`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (failed.length > 0) {
|
|
79
|
+
lines.push("\n**Could not fetch:**");
|
|
80
|
+
for (const ref of failed) {
|
|
81
|
+
lines.push(` - ${ref.url} (${ref.reason})`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return lines.join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatGaps(gaps: Gap[]): string {
|
|
89
|
+
if (gaps.length === 0) return "No significant gaps detected.";
|
|
90
|
+
|
|
91
|
+
const bySeverity = {
|
|
92
|
+
high: gaps.filter((g) => g.severity === "high"),
|
|
93
|
+
medium: gaps.filter((g) => g.severity === "medium"),
|
|
94
|
+
low: gaps.filter((g) => g.severity === "low"),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const lines: string[] = [];
|
|
98
|
+
|
|
99
|
+
if (bySeverity.high.length > 0) {
|
|
100
|
+
lines.push("**HIGH PRIORITY**");
|
|
101
|
+
for (const g of bySeverity.high) {
|
|
102
|
+
lines.push(` ⚠️ [${g.file}${g.line ? `:${g.line}` : ""}] ${g.description}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (bySeverity.medium.length > 0) {
|
|
107
|
+
lines.push("\n**MEDIUM PRIORITY**");
|
|
108
|
+
for (const g of bySeverity.medium) {
|
|
109
|
+
lines.push(` ⚡ [${g.file}${g.line ? `:${g.line}` : ""}] ${g.description}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (bySeverity.low.length > 0) {
|
|
114
|
+
lines.push("\n**LOW PRIORITY**");
|
|
115
|
+
for (const g of bySeverity.low.slice(0, 10)) {
|
|
116
|
+
lines.push(` ℹ️ [${g.file}${g.line ? `:${g.line}` : ""}] ${g.description}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return lines.join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function buildContextDoc(opts: ContextDocOptions): string {
|
|
124
|
+
const scopeSummary = [
|
|
125
|
+
...opts.activeScope.map((s) => `${s} (active)`),
|
|
126
|
+
...opts.watchedScope.map((s) => `${s} (watched)`),
|
|
127
|
+
].join(", ") || "auto-detected";
|
|
128
|
+
|
|
129
|
+
return `${DIVIDER}
|
|
130
|
+
CLIPER CONTEXT DOCUMENT
|
|
131
|
+
${DIVIDER}
|
|
132
|
+
PROJECT: ${opts.projectName}
|
|
133
|
+
GENERATED: ${opts.generatedAt}
|
|
134
|
+
BRANCH: ${opts.gitContext.branch || "unknown"}
|
|
135
|
+
SCOPED TO: ${scopeSummary}
|
|
136
|
+
${DIVIDER}
|
|
137
|
+
|
|
138
|
+
## FOLDER STRUCTURE
|
|
139
|
+
|
|
140
|
+
\`\`\`
|
|
141
|
+
${opts.fileTree}
|
|
142
|
+
\`\`\`
|
|
143
|
+
|
|
144
|
+
${DIVIDER}
|
|
145
|
+
|
|
146
|
+
## GIT CONTEXT
|
|
147
|
+
|
|
148
|
+
${formatGitContext(opts.gitContext)}
|
|
149
|
+
|
|
150
|
+
${DIVIDER}
|
|
151
|
+
|
|
152
|
+
## DEPENDENCY MAP
|
|
153
|
+
|
|
154
|
+
${opts.dependencyMap}
|
|
155
|
+
|
|
156
|
+
${DIVIDER}
|
|
157
|
+
|
|
158
|
+
## KEY FILES
|
|
159
|
+
|
|
160
|
+
${formatFileContents(opts.files)}
|
|
161
|
+
|
|
162
|
+
${DIVIDER}
|
|
163
|
+
|
|
164
|
+
## BLOCKED REFERENCES (fetched locally)
|
|
165
|
+
|
|
166
|
+
${formatReferences(opts.references)}
|
|
167
|
+
|
|
168
|
+
${DIVIDER}
|
|
169
|
+
|
|
170
|
+
## DETECTED GAPS
|
|
171
|
+
|
|
172
|
+
${formatGaps(opts.gaps)}
|
|
173
|
+
|
|
174
|
+
${DIVIDER}
|
|
175
|
+
END OF CLIPER CONTEXT DOCUMENT
|
|
176
|
+
${DIVIDER}
|
|
177
|
+
`;
|
|
178
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { FileContent } from "../scanner/fileContent";
|
|
4
|
+
|
|
5
|
+
export interface Gap {
|
|
6
|
+
type: "undocumented_function" | "missing_env_var" | "todo_fixme" | "implicit_dependency" | "stale_reference";
|
|
7
|
+
file: string;
|
|
8
|
+
line?: number;
|
|
9
|
+
description: string;
|
|
10
|
+
severity: "high" | "medium" | "low";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Detect functions/classes with no JSDoc or comments above them
|
|
14
|
+
function detectUndocumentedCode(file: FileContent): Gap[] {
|
|
15
|
+
const gaps: Gap[] = [];
|
|
16
|
+
const lines = file.content.split("\n");
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < lines.length; i++) {
|
|
19
|
+
const line = lines[i].trim();
|
|
20
|
+
const prevLine = i > 0 ? lines[i - 1].trim() : "";
|
|
21
|
+
|
|
22
|
+
const isFunctionOrClass =
|
|
23
|
+
/^(export\s+)?(async\s+)?function\s+\w+/.test(line) ||
|
|
24
|
+
/^(export\s+)?(default\s+)?class\s+\w+/.test(line) ||
|
|
25
|
+
/^(export\s+)?const\s+\w+\s*=\s*(async\s+)?\(/.test(line);
|
|
26
|
+
|
|
27
|
+
if (isFunctionOrClass) {
|
|
28
|
+
const hasComment =
|
|
29
|
+
prevLine.startsWith("//") ||
|
|
30
|
+
prevLine.startsWith("*") ||
|
|
31
|
+
prevLine.startsWith("/*") ||
|
|
32
|
+
prevLine.startsWith("/**") ||
|
|
33
|
+
prevLine === "*/";
|
|
34
|
+
|
|
35
|
+
if (!hasComment) {
|
|
36
|
+
gaps.push({
|
|
37
|
+
type: "undocumented_function",
|
|
38
|
+
file: file.relativePath,
|
|
39
|
+
line: i + 1,
|
|
40
|
+
description: `Undocumented: "${line.slice(0, 60)}..."`,
|
|
41
|
+
severity: "low",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return gaps.slice(0, 5); // Cap at 5 per file to avoid noise
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Detect TODO/FIXME/HACK comments
|
|
51
|
+
function detectTodos(file: FileContent): Gap[] {
|
|
52
|
+
const gaps: Gap[] = [];
|
|
53
|
+
const lines = file.content.split("\n");
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < lines.length; i++) {
|
|
56
|
+
const line = lines[i];
|
|
57
|
+
if (/TODO|FIXME|HACK|XXX|TEMP/i.test(line)) {
|
|
58
|
+
gaps.push({
|
|
59
|
+
type: "todo_fixme",
|
|
60
|
+
file: file.relativePath,
|
|
61
|
+
line: i + 1,
|
|
62
|
+
description: line.trim().slice(0, 100),
|
|
63
|
+
severity: /FIXME|HACK/i.test(line) ? "high" : "medium",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return gaps;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Detect env vars referenced in code but not in .env.example
|
|
72
|
+
function detectMissingEnvVars(
|
|
73
|
+
files: FileContent[],
|
|
74
|
+
projectRoot: string
|
|
75
|
+
): Gap[] {
|
|
76
|
+
const gaps: Gap[] = [];
|
|
77
|
+
const envExamplePath = path.join(projectRoot, ".env.example");
|
|
78
|
+
|
|
79
|
+
let knownEnvVars: Set<string> = new Set();
|
|
80
|
+
if (fs.existsSync(envExamplePath)) {
|
|
81
|
+
const content = fs.readFileSync(envExamplePath, "utf-8");
|
|
82
|
+
for (const line of content.split("\n")) {
|
|
83
|
+
const match = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/);
|
|
84
|
+
if (match) knownEnvVars.add(match[1]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ENV_REGEX = /process\.env\.([A-Z_][A-Z0-9_]*)/g;
|
|
89
|
+
|
|
90
|
+
for (const file of files) {
|
|
91
|
+
let match;
|
|
92
|
+
while ((match = ENV_REGEX.exec(file.content)) !== null) {
|
|
93
|
+
const varName = match[1];
|
|
94
|
+
if (!knownEnvVars.has(varName)) {
|
|
95
|
+
gaps.push({
|
|
96
|
+
type: "missing_env_var",
|
|
97
|
+
file: file.relativePath,
|
|
98
|
+
description: `process.env.${varName} used but not in .env.example`,
|
|
99
|
+
severity: "high",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Deduplicate by description
|
|
106
|
+
const seen = new Set<string>();
|
|
107
|
+
return gaps.filter((g) => {
|
|
108
|
+
if (seen.has(g.description)) return false;
|
|
109
|
+
seen.add(g.description);
|
|
110
|
+
return true;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function detectGaps(files: FileContent[], projectRoot: string): Gap[] {
|
|
115
|
+
const gaps: Gap[] = [];
|
|
116
|
+
|
|
117
|
+
for (const file of files) {
|
|
118
|
+
if (file.truncated) continue;
|
|
119
|
+
const ext = path.extname(file.relativePath);
|
|
120
|
+
if ([".ts", ".tsx", ".js", ".jsx"].includes(ext)) {
|
|
121
|
+
gaps.push(...detectUndocumentedCode(file));
|
|
122
|
+
gaps.push(...detectTodos(file));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
gaps.push(...detectMissingEnvVars(files, projectRoot));
|
|
127
|
+
|
|
128
|
+
// Sort by severity
|
|
129
|
+
const order: Record<string, number> = { high: 0, medium: 1, low: 2 };
|
|
130
|
+
return gaps.sort((a, b) => order[a.severity] - order[b.severity]);
|
|
131
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { program } from "commander";
|
|
4
|
+
import { initCommand } from "./commands/init";
|
|
5
|
+
import { syncCommand } from "./commands/sync";
|
|
6
|
+
import { scopeCommand } from "./commands/scope";
|
|
7
|
+
import { statusCommand } from "./commands/status";
|
|
8
|
+
import { exportCommand } from "./commands/export";
|
|
9
|
+
import { analyzeCommand } from "./commands/analyze";
|
|
10
|
+
const { version } = require("../package.json");
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name("cliper")
|
|
14
|
+
.description("AI context doc generator for developers")
|
|
15
|
+
.version(version);
|
|
16
|
+
|
|
17
|
+
program
|
|
18
|
+
.command("init")
|
|
19
|
+
.description("Scan project and generate context document")
|
|
20
|
+
.option("-p, --path <path>", "Project root path", process.cwd())
|
|
21
|
+
.option("--max-file-size <kb>", "Max file size to include in KB", (v) => parseInt(v), 50)
|
|
22
|
+
.action(initCommand);
|
|
23
|
+
|
|
24
|
+
program
|
|
25
|
+
.command("sync")
|
|
26
|
+
.description("Refresh stale sections of the context document")
|
|
27
|
+
.option("--watch", "Auto-refresh on git events")
|
|
28
|
+
.action(syncCommand);
|
|
29
|
+
|
|
30
|
+
program
|
|
31
|
+
.command("scope")
|
|
32
|
+
.description("Manage active scope")
|
|
33
|
+
.argument("<action>", "add | remove | watch | list")
|
|
34
|
+
.argument("[path]", "File or directory path")
|
|
35
|
+
.action(scopeCommand);
|
|
36
|
+
|
|
37
|
+
program
|
|
38
|
+
.command("status")
|
|
39
|
+
.description("Show what is fresh, stale, and in scope")
|
|
40
|
+
.action(statusCommand);
|
|
41
|
+
|
|
42
|
+
program
|
|
43
|
+
.command("export")
|
|
44
|
+
.description("Print context doc to stdout")
|
|
45
|
+
.option("--format <format>", "Output format: md or txt", "md")
|
|
46
|
+
.action(exportCommand);
|
|
47
|
+
|
|
48
|
+
program
|
|
49
|
+
.command("analyze")
|
|
50
|
+
.description("Analyze context doc and generate optimized AI prompt")
|
|
51
|
+
.requiredOption("--model <model>", "Target model: claude or chatgpt")
|
|
52
|
+
.action(analyzeCommand);
|
|
53
|
+
|
|
54
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { glob } from "glob";
|
|
4
|
+
|
|
5
|
+
export interface ResolvedReference {
|
|
6
|
+
url: string;
|
|
7
|
+
source: string; // which file it was found in
|
|
8
|
+
status: "fetched" | "blocked" | "failed" | "skipped";
|
|
9
|
+
content?: string;
|
|
10
|
+
reason?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const URL_REGEX = /https?:\/\/[^\s\)\]\>"']+/g;
|
|
14
|
+
|
|
15
|
+
const ALWAYS_BLOCKED_PATTERNS = [
|
|
16
|
+
/robots\.txt/i,
|
|
17
|
+
/localhost/i,
|
|
18
|
+
/127\.0\.0\.1/,
|
|
19
|
+
/internal\./,
|
|
20
|
+
/\.local\//,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const SKIP_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".pdf", ".zip"];
|
|
24
|
+
|
|
25
|
+
function shouldSkip(url: string): boolean {
|
|
26
|
+
if (SKIP_EXTENSIONS.some((ext) => url.toLowerCase().includes(ext))) return true;
|
|
27
|
+
if (url.includes("shields.io")) return true;
|
|
28
|
+
if (url.includes("badge")) return true;
|
|
29
|
+
if (url.includes("github.com/") && url.includes("/actions/")) return true;
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function fetchUrl(url: string): Promise<{ ok: boolean; content?: string; reason?: string }> {
|
|
34
|
+
try {
|
|
35
|
+
const { default: fetch } = await import("node-fetch");
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
38
|
+
|
|
39
|
+
const res = await fetch(url, {
|
|
40
|
+
signal: controller.signal as any,
|
|
41
|
+
headers: {
|
|
42
|
+
"User-Agent": "Cliper/1.0 context-doc-generator",
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
clearTimeout(timeout);
|
|
47
|
+
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
return { ok: false, reason: `HTTP ${res.status}` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
53
|
+
if (!contentType.includes("text") && !contentType.includes("json")) {
|
|
54
|
+
return { ok: false, reason: "Non-text content" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const text = await res.text();
|
|
58
|
+
|
|
59
|
+
// Strip HTML tags for cleaner context
|
|
60
|
+
const cleaned = text
|
|
61
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
|
62
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
|
|
63
|
+
.replace(/<[^>]+>/g, " ")
|
|
64
|
+
.replace(/\s+/g, " ")
|
|
65
|
+
.trim()
|
|
66
|
+
.slice(0, 3000); // Cap at 3000 chars per URL
|
|
67
|
+
|
|
68
|
+
return { ok: true, content: cleaned };
|
|
69
|
+
} catch (err: any) {
|
|
70
|
+
return { ok: false, reason: err.message ?? "Network error" };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function resolveBlockedReferences(
|
|
75
|
+
projectRoot: string
|
|
76
|
+
): Promise<ResolvedReference[]> {
|
|
77
|
+
const markdownFiles = await glob("**/*.md", {
|
|
78
|
+
cwd: projectRoot,
|
|
79
|
+
ignore: ["node_modules/**", ".git/**", "dist/**"],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const urlMap = new Map<string, string>(); // url -> source file
|
|
83
|
+
|
|
84
|
+
for (const mdFile of markdownFiles) {
|
|
85
|
+
const fullPath = path.join(projectRoot, mdFile);
|
|
86
|
+
try {
|
|
87
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
88
|
+
const matches = content.match(URL_REGEX) ?? [];
|
|
89
|
+
for (const url of matches) {
|
|
90
|
+
if (!urlMap.has(url)) urlMap.set(url, mdFile);
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// Skip unreadable files
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const results: ResolvedReference[] = [];
|
|
98
|
+
|
|
99
|
+
for (const [url, source] of urlMap) {
|
|
100
|
+
if (shouldSkip(url)) {
|
|
101
|
+
results.push({ url, source, status: "skipped", reason: "Badge/asset URL" });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (ALWAYS_BLOCKED_PATTERNS.some((p) => p.test(url))) {
|
|
106
|
+
results.push({ url, source, status: "blocked", reason: "Internal/local URL" });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const result = await fetchUrl(url);
|
|
111
|
+
if (result.ok && result.content) {
|
|
112
|
+
results.push({ url, source, status: "fetched", content: result.content });
|
|
113
|
+
} else {
|
|
114
|
+
results.push({ url, source, status: "failed", reason: result.reason });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return results;
|
|
119
|
+
}
|