@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.
Files changed (68) hide show
  1. package/README.md +266 -0
  2. package/dist/commands/analyze.d.ts +6 -0
  3. package/dist/commands/analyze.d.ts.map +1 -0
  4. package/dist/commands/analyze.js +216 -0
  5. package/dist/commands/export.d.ts +6 -0
  6. package/dist/commands/export.d.ts.map +1 -0
  7. package/dist/commands/export.js +64 -0
  8. package/dist/commands/init.d.ts +7 -0
  9. package/dist/commands/init.d.ts.map +1 -0
  10. package/dist/commands/init.js +173 -0
  11. package/dist/commands/scope.d.ts +2 -0
  12. package/dist/commands/scope.d.ts.map +1 -0
  13. package/dist/commands/scope.js +124 -0
  14. package/dist/commands/status.d.ts +2 -0
  15. package/dist/commands/status.d.ts.map +1 -0
  16. package/dist/commands/status.js +100 -0
  17. package/dist/commands/sync.d.ts +6 -0
  18. package/dist/commands/sync.d.ts.map +1 -0
  19. package/dist/commands/sync.js +83 -0
  20. package/dist/context/builder.d.ts +19 -0
  21. package/dist/context/builder.d.ts.map +1 -0
  22. package/dist/context/builder.js +143 -0
  23. package/dist/gaps/detector.d.ts +10 -0
  24. package/dist/gaps/detector.d.ts.map +1 -0
  25. package/dist/gaps/detector.js +139 -0
  26. package/dist/index.d.ts +3 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +48 -0
  29. package/dist/resolver/urlFetcher.d.ts +9 -0
  30. package/dist/resolver/urlFetcher.d.ts.map +1 -0
  31. package/dist/resolver/urlFetcher.js +134 -0
  32. package/dist/scanner/dependencies.d.ts +14 -0
  33. package/dist/scanner/dependencies.d.ts.map +1 -0
  34. package/dist/scanner/dependencies.js +199 -0
  35. package/dist/scanner/fileContent.d.ts +8 -0
  36. package/dist/scanner/fileContent.d.ts.map +1 -0
  37. package/dist/scanner/fileContent.js +133 -0
  38. package/dist/scanner/fileTree.d.ts +2 -0
  39. package/dist/scanner/fileTree.d.ts.map +1 -0
  40. package/dist/scanner/fileTree.js +152 -0
  41. package/dist/scanner/gitContext.d.ts +19 -0
  42. package/dist/scanner/gitContext.d.ts.map +1 -0
  43. package/dist/scanner/gitContext.js +60 -0
  44. package/dist/scope/autoScope.d.ts +2 -0
  45. package/dist/scope/autoScope.d.ts.map +1 -0
  46. package/dist/scope/autoScope.js +226 -0
  47. package/dist/scope/config.d.ts +10 -0
  48. package/dist/scope/config.d.ts.map +1 -0
  49. package/dist/scope/config.js +71 -0
  50. package/index.js +2 -0
  51. package/package.json +37 -0
  52. package/src/commands/analyze.ts +201 -0
  53. package/src/commands/export.ts +33 -0
  54. package/src/commands/init.ts +174 -0
  55. package/src/commands/scope.ts +77 -0
  56. package/src/commands/status.ts +67 -0
  57. package/src/commands/sync.ts +51 -0
  58. package/src/context/builder.ts +178 -0
  59. package/src/gaps/detector.ts +131 -0
  60. package/src/index.ts +54 -0
  61. package/src/resolver/urlFetcher.ts +119 -0
  62. package/src/scanner/dependencies.ts +196 -0
  63. package/src/scanner/fileContent.ts +121 -0
  64. package/src/scanner/fileTree.ts +149 -0
  65. package/src/scanner/gitContext.ts +74 -0
  66. package/src/scope/autoScope.ts +182 -0
  67. package/src/scope/config.ts +39 -0
  68. 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
+ }