@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,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.getCliperDir = getCliperDir;
|
|
37
|
+
exports.getScopeConfigPath = getScopeConfigPath;
|
|
38
|
+
exports.loadScopeConfig = loadScopeConfig;
|
|
39
|
+
exports.saveScopeConfig = saveScopeConfig;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const DEFAULT_CONFIG = {
|
|
43
|
+
active: [],
|
|
44
|
+
watched: [],
|
|
45
|
+
updatedAt: new Date().toISOString(),
|
|
46
|
+
};
|
|
47
|
+
function getCliperDir(projectRoot) {
|
|
48
|
+
return path.join(projectRoot, ".cliper");
|
|
49
|
+
}
|
|
50
|
+
function getScopeConfigPath(projectRoot) {
|
|
51
|
+
return path.join(getCliperDir(projectRoot), "scope.json");
|
|
52
|
+
}
|
|
53
|
+
function loadScopeConfig(projectRoot) {
|
|
54
|
+
const configPath = getScopeConfigPath(projectRoot);
|
|
55
|
+
if (!fs.existsSync(configPath))
|
|
56
|
+
return { ...DEFAULT_CONFIG };
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { ...DEFAULT_CONFIG };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function saveScopeConfig(projectRoot, config) {
|
|
65
|
+
const dir = getCliperDir(projectRoot);
|
|
66
|
+
if (!fs.existsSync(dir))
|
|
67
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
68
|
+
config.updatedAt = new Date().toISOString();
|
|
69
|
+
fs.writeFileSync(getScopeConfigPath(projectRoot), JSON.stringify(config, null, 2));
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=config.js.map
|
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cliperhq/cliper",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Generate rich, AI-ready context documents from your codebase. Always fresh, always scoped — for Claude, ChatGPT, and beyond.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cliper": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "ts-node src/index.ts",
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"lint": "eslint src/**/*.ts"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"chalk": "^5.3.0",
|
|
17
|
+
"cheerio": "^1.0.0",
|
|
18
|
+
"cli-table3": "^0.6.3",
|
|
19
|
+
"commander": "^12.0.0",
|
|
20
|
+
"glob": "^10.3.10",
|
|
21
|
+
"ignore": "^5.3.1",
|
|
22
|
+
"marked": "^12.0.0",
|
|
23
|
+
"node-fetch": "^3.3.2",
|
|
24
|
+
"ora": "^8.0.1",
|
|
25
|
+
"simple-git": "^3.22.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/cheerio": "^0.22.35",
|
|
29
|
+
"@types/node": "^20.12.7",
|
|
30
|
+
"ts-node": "^10.9.2",
|
|
31
|
+
"typescript": "^5.4.5"
|
|
32
|
+
},
|
|
33
|
+
"license": "ISC",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18.0.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
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
|
+
|
|
7
|
+
type Model = "claude" | "chatgpt";
|
|
8
|
+
|
|
9
|
+
interface AnalyzeOptions {
|
|
10
|
+
model: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const CLAUDE_SYSTEM = `You are an expert software architect analyzing a codebase context document.
|
|
14
|
+
Your job is to transform raw codebase data into a highly optimized prompt for a Claude AI coding session.
|
|
15
|
+
|
|
16
|
+
Claude thinks best when given:
|
|
17
|
+
- Clear project narrative (what it is, why it exists)
|
|
18
|
+
- Explicit constraints and existing decisions to respect
|
|
19
|
+
- The specific area of focus with surrounding context
|
|
20
|
+
- What NOT to change or break
|
|
21
|
+
- Open questions or uncertainties the developer has
|
|
22
|
+
|
|
23
|
+
Your output must be a ready-to-paste prompt. No preamble. No explanation. Just the prompt itself.`;
|
|
24
|
+
|
|
25
|
+
const CHATGPT_SYSTEM = `You are an expert software architect analyzing a codebase context document.
|
|
26
|
+
Your job is to transform raw codebase data into a highly optimized prompt for a ChatGPT coding session.
|
|
27
|
+
|
|
28
|
+
ChatGPT works best when given:
|
|
29
|
+
- Numbered, structured context sections
|
|
30
|
+
- Explicit file paths and code blocks
|
|
31
|
+
- Clear task definition separated from context
|
|
32
|
+
- Constraints listed as bullet points
|
|
33
|
+
- A direct question or task at the end
|
|
34
|
+
|
|
35
|
+
Your output must be a ready-to-paste prompt. No preamble. No explanation. Just the prompt itself.`;
|
|
36
|
+
|
|
37
|
+
const CLAUDE_USER = (contextDoc: string) => `Here is the raw codebase context document generated by Cliper:
|
|
38
|
+
|
|
39
|
+
<context>
|
|
40
|
+
${contextDoc}
|
|
41
|
+
</context>
|
|
42
|
+
|
|
43
|
+
Transform this into an optimized Claude prompt following these rules:
|
|
44
|
+
|
|
45
|
+
CRITICAL ACCURACY RULES:
|
|
46
|
+
- Never expand acronyms unless the context doc explicitly defines them. If you see "SPEL" and the doc doesn't say what it stands for, write "SPEL" — do not invent an expansion.
|
|
47
|
+
- Never infer what a framework "is based on" from inspiration references. "Inspired by Anchor for Solana" does NOT mean the project runs on Solana.
|
|
48
|
+
- Include ALL macros and annotations found in the source code, not just the most common ones. If you see #[require_admin], include it.
|
|
49
|
+
- If a file was truncated, note that there may be additional macros or patterns not visible.
|
|
50
|
+
|
|
51
|
+
FORMAT RULES:
|
|
52
|
+
1. Opens with a concise project narrative (2-3 sentences max) — only state facts explicitly in the context doc
|
|
53
|
+
2. Summarizes the current branch focus and recent work
|
|
54
|
+
3. Lists key architectural decisions already made that must be respected
|
|
55
|
+
4. Highlights the most important files and their roles
|
|
56
|
+
5. Lists ALL macros/annotations found across all files
|
|
57
|
+
6. Surfaces any detected gaps the developer should be aware of
|
|
58
|
+
7. Ends with: "## What I need help with today:"
|
|
59
|
+
|
|
60
|
+
Make it feel like a natural briefing. Never invent details not present in the context doc.`;
|
|
61
|
+
|
|
62
|
+
const CHATGPT_USER = (contextDoc: string) => `Here is the raw codebase context document generated by Cliper:
|
|
63
|
+
|
|
64
|
+
${contextDoc}
|
|
65
|
+
|
|
66
|
+
Transform this into an optimized chatgpt prompt following these rules:
|
|
67
|
+
|
|
68
|
+
CRITICAL ACCURACY RULES:
|
|
69
|
+
- Never expand acronyms unless the context doc explicitly defines them. If you see "SPEL" and the doc doesn't say what it stands for, write "SPEL" — do not invent an expansion.
|
|
70
|
+
- Never infer what a framework "is based on" from inspiration references. "Inspired by Anchor for Solana" does NOT mean the project runs on Solana.
|
|
71
|
+
- Include ALL macros and annotations found in the source code, not just the most common ones. If you see #[require_admin], include it.
|
|
72
|
+
- If a file was truncated, note that there may be additional macros or patterns not visible.
|
|
73
|
+
|
|
74
|
+
FORMAT RULES:
|
|
75
|
+
1. Starts with "## Project Context" as a header
|
|
76
|
+
2. Uses numbered sections for: Project Overview, Tech Stack, Current Branch & Focus, Key Files, Constraints & Decisions, Known Gaps
|
|
77
|
+
3. Uses bullet points and code blocks for file paths and snippets
|
|
78
|
+
4. Keeps each section concise — no more than 5 bullet points per section
|
|
79
|
+
5. Ends with "## Task:" followed by a blank line for the developer to fill in
|
|
80
|
+
|
|
81
|
+
Format it for maximum clarity and scannability. ChatGPT responds best to structured, explicitly labeled context.`;
|
|
82
|
+
|
|
83
|
+
async function callAnthropicAPI(contextDoc: string): Promise<string> {
|
|
84
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: {
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
"x-api-key": process.env.ANTHROPIC_API_KEY ?? "",
|
|
89
|
+
"anthropic-version": "2023-06-01",
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
model: "claude-sonnet-4-20250514",
|
|
93
|
+
max_tokens: 2000,
|
|
94
|
+
system: CLAUDE_SYSTEM,
|
|
95
|
+
messages: [{ role: "user", content: CLAUDE_USER(contextDoc) }],
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
const err = await response.text();
|
|
101
|
+
throw new Error(`Anthropic API error: ${response.status} — ${err}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const data = await response.json() as any;
|
|
105
|
+
return data.content?.[0]?.text ?? "";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function callOpenAIAPI(contextDoc: string): Promise<string> {
|
|
109
|
+
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: {
|
|
112
|
+
"Content-Type": "application/json",
|
|
113
|
+
"Authorization": `Bearer ${process.env.OPENAI_API_KEY ?? ""}`,
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify({
|
|
116
|
+
model: "gpt-4o",
|
|
117
|
+
max_tokens: 2000,
|
|
118
|
+
messages: [
|
|
119
|
+
{ role: "system", content: CHATGPT_SYSTEM },
|
|
120
|
+
{ role: "user", content: CHATGPT_USER(contextDoc) },
|
|
121
|
+
],
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
const err = await response.text();
|
|
127
|
+
throw new Error(`OpenAI API error: ${response.status} — ${err}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const data = await response.json() as any;
|
|
131
|
+
return data.choices?.[0]?.message?.content ?? "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function analyzeCommand(options: AnalyzeOptions): Promise<void> {
|
|
135
|
+
const projectRoot = process.cwd();
|
|
136
|
+
const cliperDir = getCliperDir(projectRoot);
|
|
137
|
+
const contextPath = path.join(cliperDir, "context.md");
|
|
138
|
+
|
|
139
|
+
// Check context doc exists
|
|
140
|
+
if (!fs.existsSync(contextPath)) {
|
|
141
|
+
console.error(chalk.red("\n No context doc found. Run cliper init first.\n"));
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const contextDoc = fs.readFileSync(contextPath, "utf-8");
|
|
146
|
+
const model = options.model.toLowerCase() as Model;
|
|
147
|
+
|
|
148
|
+
// Validate model choice
|
|
149
|
+
if (!["claude", "chatgpt"].includes(model)) {
|
|
150
|
+
console.error(chalk.red(`\n Unknown model: ${options.model}`));
|
|
151
|
+
console.error(chalk.gray(" Usage: cliper analyze --model claude"));
|
|
152
|
+
console.error(chalk.gray(" cliper analyze --model chatgpt\n"));
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check API key
|
|
157
|
+
if (model === "claude" && !process.env.ANTHROPIC_API_KEY) {
|
|
158
|
+
console.error(chalk.red("\n ANTHROPIC_API_KEY not set."));
|
|
159
|
+
console.error(chalk.gray(" Export it first: export ANTHROPIC_API_KEY=your_key_here\n"));
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (model === "chatgpt" && !process.env.OPENAI_API_KEY) {
|
|
164
|
+
console.error(chalk.red("\n OPENAI_API_KEY not set."));
|
|
165
|
+
console.error(chalk.gray(" Export it first: export OPENAI_API_KEY=your_key_here\n"));
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
console.log(chalk.bold.cyan(`\n cliper analyze — ${model}\n`));
|
|
170
|
+
|
|
171
|
+
const spinner = ora(`Analyzing context doc with ${model === "claude" ? "Claude" : "ChatGPT"}...`).start();
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
let prompt: string;
|
|
175
|
+
|
|
176
|
+
if (model === "claude") {
|
|
177
|
+
prompt = await callAnthropicAPI(contextDoc);
|
|
178
|
+
const outputPath = path.join(cliperDir, "prompt-claude.md");
|
|
179
|
+
fs.writeFileSync(outputPath, prompt, "utf-8");
|
|
180
|
+
spinner.succeed(chalk.green("Claude prompt generated"));
|
|
181
|
+
console.log(chalk.gray(`\n Saved: ${outputPath}\n`));
|
|
182
|
+
console.log(chalk.cyan(" Copy to clipboard:"));
|
|
183
|
+
console.log(chalk.white(" cliper analyze --model claude | pbcopy\n"));
|
|
184
|
+
console.log(chalk.cyan(" Or open directly:"));
|
|
185
|
+
console.log(chalk.white(` cat ${outputPath} | pbcopy\n`));
|
|
186
|
+
} else {
|
|
187
|
+
prompt = await callOpenAIAPI(contextDoc);
|
|
188
|
+
const outputPath = path.join(cliperDir, "prompt-gpt.md");
|
|
189
|
+
fs.writeFileSync(outputPath, prompt, "utf-8");
|
|
190
|
+
spinner.succeed(chalk.green("ChatGPT prompt generated"));
|
|
191
|
+
console.log(chalk.gray(`\n Saved: ${outputPath}\n`));
|
|
192
|
+
console.log(chalk.cyan(" Copy to clipboard:"));
|
|
193
|
+
console.log(chalk.white(` cat ${outputPath} | pbcopy\n`));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
} catch (err: any) {
|
|
197
|
+
spinner.fail(chalk.red("Analysis failed"));
|
|
198
|
+
console.error(chalk.red(`\n ${err.message}\n`));
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { getCliperDir } from "../scope/config";
|
|
5
|
+
|
|
6
|
+
interface ExportOptions {
|
|
7
|
+
format: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function exportCommand(options: ExportOptions): Promise<void> {
|
|
11
|
+
const projectRoot = process.cwd();
|
|
12
|
+
const contextPath = path.join(getCliperDir(projectRoot), "context.md");
|
|
13
|
+
|
|
14
|
+
if (!fs.existsSync(contextPath)) {
|
|
15
|
+
console.error(chalk.red("\n No context doc found. Run cliper init first.\n"));
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let content = fs.readFileSync(contextPath, "utf-8");
|
|
20
|
+
|
|
21
|
+
if (options.format === "txt") {
|
|
22
|
+
// Strip markdown formatting for plain text output
|
|
23
|
+
content = content
|
|
24
|
+
.replace(/```[\w]*\n/g, "")
|
|
25
|
+
.replace(/```/g, "")
|
|
26
|
+
.replace(/#{1,6}\s/g, "")
|
|
27
|
+
.replace(/\*\*/g, "")
|
|
28
|
+
.replace(/`/g, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Write to stdout — designed to be piped
|
|
32
|
+
process.stdout.write(content);
|
|
33
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { buildDependencyMap, formatDependencyMap } from "../scanner/dependencies";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import simpleGit from "simple-git";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import ora from "ora";
|
|
7
|
+
import { autoDetectScope } from "../scope/autoScope";
|
|
8
|
+
import { loadScopeConfig, saveScopeConfig, getCliperDir } from "../scope/config";
|
|
9
|
+
import { generateFileTree } from "../scanner/fileTree";
|
|
10
|
+
import { extractFileContents } from "../scanner/fileContent";
|
|
11
|
+
import { getGitContext } from "../scanner/gitContext";
|
|
12
|
+
import { resolveBlockedReferences } from "../resolver/urlFetcher";
|
|
13
|
+
import { detectGaps } from "../gaps/detector";
|
|
14
|
+
import { buildContextDoc } from "../context/builder";
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
interface InitOptions {
|
|
19
|
+
path: string;
|
|
20
|
+
maxFileSize?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
export async function initCommand(options: InitOptions): Promise<void> {
|
|
25
|
+
const projectRoot = path.resolve(options.path);
|
|
26
|
+
|
|
27
|
+
if (!fs.existsSync(projectRoot)) {
|
|
28
|
+
console.error(chalk.red(`Project path not found: ${projectRoot}`));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(chalk.bold.cyan("\n cliper init\n"));
|
|
33
|
+
console.log(chalk.gray(` Project: ${projectRoot}\n`));
|
|
34
|
+
|
|
35
|
+
// Step 1: Load or create scope config
|
|
36
|
+
const spinner = ora("Detecting scope from git activity...").start();
|
|
37
|
+
let scopeConfig = loadScopeConfig(projectRoot);
|
|
38
|
+
|
|
39
|
+
// Always re-run auto-detection and merge with any manual additions
|
|
40
|
+
const autoScope = await autoDetectScope(projectRoot);
|
|
41
|
+
const manualAdditions = scopeConfig.active.filter((p) => !autoScope.includes(p));
|
|
42
|
+
scopeConfig.active = [...new Set([...autoScope, ...manualAdditions])];
|
|
43
|
+
saveScopeConfig(projectRoot, scopeConfig);
|
|
44
|
+
spinner.succeed(
|
|
45
|
+
chalk.green(`Scope detected: ${scopeConfig.active.length} paths active, ${scopeConfig.watched.length} watched`)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Step 2: Generate file tree
|
|
49
|
+
const treeSpinner = ora("Building annotated file tree...").start();
|
|
50
|
+
const fileTree = generateFileTree(projectRoot, scopeConfig.active, scopeConfig.watched);
|
|
51
|
+
treeSpinner.succeed(chalk.green("File tree built"));
|
|
52
|
+
|
|
53
|
+
// Step 3: Extract file contents
|
|
54
|
+
const contentSpinner = ora("Extracting file contents within scope...").start();
|
|
55
|
+
const files = await extractFileContents(
|
|
56
|
+
projectRoot,
|
|
57
|
+
scopeConfig.active,
|
|
58
|
+
scopeConfig.watched,
|
|
59
|
+
options.maxFileSize ?? 50
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
contentSpinner.succeed(chalk.green(`Extracted ${files.length} files`));
|
|
63
|
+
|
|
64
|
+
// Step 4: Git context
|
|
65
|
+
const gitSpinner = ora("Reading git context...").start();
|
|
66
|
+
const gitContext = await getGitContext(projectRoot);
|
|
67
|
+
gitSpinner.succeed(
|
|
68
|
+
gitContext.isGitRepo
|
|
69
|
+
? chalk.green(`Git: ${gitContext.branch} — ${gitContext.lastCommit?.message ?? "no commits"}`)
|
|
70
|
+
: chalk.yellow("Not a git repository")
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Step 5: Resolve blocked references
|
|
74
|
+
const refSpinner = ora("Fetching blocked external references...").start();
|
|
75
|
+
const references = await resolveBlockedReferences(projectRoot);
|
|
76
|
+
const fetched = references.filter((r) => r.status === "fetched").length;
|
|
77
|
+
const failed = references.filter((r) => r.status === "failed").length;
|
|
78
|
+
refSpinner.succeed(chalk.green(`References: ${fetched} fetched, ${failed} failed`));
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
// Step 6: Detect gaps
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
const gapSpinner = ora("Detecting gaps and undocumented patterns...").start();
|
|
86
|
+
const gaps = detectGaps(files, projectRoot);
|
|
87
|
+
gapSpinner.succeed(
|
|
88
|
+
gaps.length > 0
|
|
89
|
+
? chalk.yellow(`Found ${gaps.length} gaps (${gaps.filter((g) => g.severity === "high").length} high priority)`)
|
|
90
|
+
: chalk.green("No significant gaps detected")
|
|
91
|
+
);
|
|
92
|
+
const depSpinner = ora("Mapping dependencies...").start();
|
|
93
|
+
const dependencyMap = buildDependencyMap(files);
|
|
94
|
+
depSpinner.succeed(chalk.green(`Dependency map: ${dependencyMap.edges.length} edges, ${dependencyMap.externalPackages.length} external packages`));
|
|
95
|
+
|
|
96
|
+
// Step 7: Build context doc
|
|
97
|
+
const buildSpinner = ora("Building context document...").start();
|
|
98
|
+
const projectName = path.basename(projectRoot);
|
|
99
|
+
const contextDoc = buildContextDoc({
|
|
100
|
+
projectRoot,
|
|
101
|
+
projectName,
|
|
102
|
+
activeScope: scopeConfig.active,
|
|
103
|
+
watchedScope: scopeConfig.watched,
|
|
104
|
+
fileTree,
|
|
105
|
+
files,
|
|
106
|
+
gitContext,
|
|
107
|
+
references,
|
|
108
|
+
gaps,
|
|
109
|
+
generatedAt: new Date().toISOString(),
|
|
110
|
+
dependencyMap: formatDependencyMap(dependencyMap),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Step 8: Write to disk
|
|
114
|
+
const cliperDir = getCliperDir(projectRoot);
|
|
115
|
+
if (!fs.existsSync(cliperDir)) fs.mkdirSync(cliperDir, { recursive: true });
|
|
116
|
+
const contextPath = path.join(cliperDir, "context.md");
|
|
117
|
+
fs.writeFileSync(contextPath, contextDoc, "utf-8");
|
|
118
|
+
buildSpinner.succeed(chalk.green("Context document built"));
|
|
119
|
+
|
|
120
|
+
// Auto-manage .gitignore — add anything cliper introduces that shouldn't be committed
|
|
121
|
+
const gitignorePath = path.join(projectRoot, ".gitignore");
|
|
122
|
+
|
|
123
|
+
const entriesToEnsure: Array<{ check: string; line: string }> = [
|
|
124
|
+
{ check: "node_modules", line: "node_modules/" },
|
|
125
|
+
{ check: "package-lock.json", line: "package-lock.json" },
|
|
126
|
+
{ check: ".cliper/cache", line: ".cliper/cache/" },
|
|
127
|
+
{ check: ".cliper/prompt-", line: ".cliper/prompt-*.md" },
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
let gitignoreContent = fs.existsSync(gitignorePath)
|
|
131
|
+
? fs.readFileSync(gitignorePath, "utf-8")
|
|
132
|
+
: "";
|
|
133
|
+
|
|
134
|
+
const missingEntries = entriesToEnsure.filter((e) => !gitignoreContent.includes(e.check));
|
|
135
|
+
|
|
136
|
+
if (missingEntries.length > 0) {
|
|
137
|
+
const block = "\n# Cliper — auto-managed\n" + missingEntries.map((e) => e.line).join("\n") + "\n";
|
|
138
|
+
if (fs.existsSync(gitignorePath)) {
|
|
139
|
+
fs.appendFileSync(gitignorePath, block);
|
|
140
|
+
} else {
|
|
141
|
+
fs.writeFileSync(gitignorePath, block.trimStart());
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Remove node_modules from git tracking if accidentally staged
|
|
146
|
+
try {
|
|
147
|
+
const gitInstance = simpleGit(projectRoot);
|
|
148
|
+
const tracked = await gitInstance.raw(["ls-files", "node_modules"]);
|
|
149
|
+
if (tracked.trim().length > 0) {
|
|
150
|
+
await gitInstance.raw(["rm", "-r", "--cached", "node_modules/"]);
|
|
151
|
+
console.log(chalk.yellow(" ⚡ Removed node_modules from git tracking"));
|
|
152
|
+
}
|
|
153
|
+
const lockTracked = await gitInstance.raw(["ls-files", "package-lock.json"]);
|
|
154
|
+
if (lockTracked.trim().length > 0) {
|
|
155
|
+
await gitInstance.raw(["rm", "--cached", "package-lock.json"]);
|
|
156
|
+
console.log(chalk.yellow(" ⚡ Removed package-lock.json from git tracking"));
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
// Not a git repo or already clean — skip silently
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Summary
|
|
163
|
+
const sizeKB = Math.round(Buffer.byteLength(contextDoc, "utf-8") / 1024);
|
|
164
|
+
const estimatedTokens = Math.round(contextDoc.length / 4);
|
|
165
|
+
|
|
166
|
+
console.log(chalk.bold.green("\n ✓ Context document ready\n"));
|
|
167
|
+
console.log(chalk.gray(` Location: ${contextPath}`));
|
|
168
|
+
console.log(chalk.gray(` Size: ${sizeKB}KB (~${estimatedTokens.toLocaleString()} tokens)`));
|
|
169
|
+
console.log(chalk.gray(` Files: ${files.length} files in context`));
|
|
170
|
+
console.log(chalk.gray(` Gaps: ${gaps.length} detected\n`));
|
|
171
|
+
console.log(chalk.cyan(" Copy to clipboard:"));
|
|
172
|
+
console.log(chalk.white(" cliper export | pbcopy # macOS"));
|
|
173
|
+
console.log(chalk.white(" cliper export | xclip # Linux\n"));
|
|
174
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { loadScopeConfig, saveScopeConfig } from "../scope/config";
|
|
4
|
+
|
|
5
|
+
export async function scopeCommand(action: string, scopePath?: string): Promise<void> {
|
|
6
|
+
const projectRoot = process.cwd();
|
|
7
|
+
const config = loadScopeConfig(projectRoot);
|
|
8
|
+
|
|
9
|
+
switch (action) {
|
|
10
|
+
case "add": {
|
|
11
|
+
if (!scopePath) { console.error(chalk.red(" Please provide a path.")); process.exit(1); }
|
|
12
|
+
const normalized = path.normalize(scopePath);
|
|
13
|
+
if (!config.active.includes(normalized)) {
|
|
14
|
+
config.active.push(normalized);
|
|
15
|
+
saveScopeConfig(projectRoot, config);
|
|
16
|
+
console.log(chalk.green(`\n ✓ Added to active scope: ${normalized}\n`));
|
|
17
|
+
console.log(chalk.gray(" Run cliper sync to update the context doc.\n"));
|
|
18
|
+
} else {
|
|
19
|
+
console.log(chalk.yellow(`\n Already in scope: ${normalized}\n`));
|
|
20
|
+
}
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
case "watch": {
|
|
25
|
+
if (!scopePath) { console.error(chalk.red(" Please provide a path.")); process.exit(1); }
|
|
26
|
+
const normalized = path.normalize(scopePath);
|
|
27
|
+
if (config.watched.length >= 15) {
|
|
28
|
+
console.log(chalk.yellow("\n Watch list is capped at 15 files. Remove one first:\n"));
|
|
29
|
+
console.log(chalk.gray(" cliper scope remove <path>\n"));
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
if (!config.watched.includes(normalized)) {
|
|
33
|
+
config.watched.push(normalized);
|
|
34
|
+
saveScopeConfig(projectRoot, config);
|
|
35
|
+
console.log(chalk.green(`\n ✓ Added to watch list: ${normalized}\n`));
|
|
36
|
+
} else {
|
|
37
|
+
console.log(chalk.yellow(`\n Already watched: ${normalized}\n`));
|
|
38
|
+
}
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
case "remove": {
|
|
43
|
+
if (!scopePath) { console.error(chalk.red(" Please provide a path.")); process.exit(1); }
|
|
44
|
+
const normalized = path.normalize(scopePath);
|
|
45
|
+
config.active = config.active.filter((p) => p !== normalized);
|
|
46
|
+
config.watched = config.watched.filter((p) => p !== normalized);
|
|
47
|
+
saveScopeConfig(projectRoot, config);
|
|
48
|
+
console.log(chalk.green(`\n ✓ Removed from scope: ${normalized}\n`));
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
case "list": {
|
|
53
|
+
console.log(chalk.bold.cyan("\n Current Scope\n"));
|
|
54
|
+
if (config.active.length === 0 && config.watched.length === 0) {
|
|
55
|
+
console.log(chalk.gray(" No scope configured. Run cliper init to auto-detect.\n"));
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
if (config.active.length > 0) {
|
|
59
|
+
console.log(chalk.bold(" Active scope:"));
|
|
60
|
+
for (const p of config.active) console.log(chalk.white(` • ${p}`));
|
|
61
|
+
}
|
|
62
|
+
if (config.watched.length > 0) {
|
|
63
|
+
console.log(chalk.bold("\n Watch list:"));
|
|
64
|
+
for (const p of config.watched) console.log(chalk.white(` • ${p}`));
|
|
65
|
+
console.log(chalk.gray(`\n (${config.watched.length}/15 watch slots used)`));
|
|
66
|
+
}
|
|
67
|
+
console.log();
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
default: {
|
|
72
|
+
console.error(chalk.red(`\n Unknown action: ${action}`));
|
|
73
|
+
console.log(chalk.gray(" Usage: cliper scope <add|remove|watch|list> [path]\n"));
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { loadScopeConfig, getCliperDir } from "../scope/config";
|
|
5
|
+
import { getGitContext } from "../scanner/gitContext";
|
|
6
|
+
|
|
7
|
+
export async function statusCommand(): Promise<void> {
|
|
8
|
+
const projectRoot = process.cwd();
|
|
9
|
+
const cliperDir = getCliperDir(projectRoot);
|
|
10
|
+
const contextPath = path.join(cliperDir, "context.md");
|
|
11
|
+
const config = loadScopeConfig(projectRoot);
|
|
12
|
+
|
|
13
|
+
console.log(chalk.bold.cyan("\n cliper status\n"));
|
|
14
|
+
|
|
15
|
+
// Context doc status
|
|
16
|
+
if (!fs.existsSync(contextPath)) {
|
|
17
|
+
console.log(chalk.red(" ✗ No context doc found"));
|
|
18
|
+
console.log(chalk.gray(" Run: cliper init\n"));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const stat = fs.statSync(contextPath);
|
|
23
|
+
const ageMs = Date.now() - stat.mtime.getTime();
|
|
24
|
+
const ageHours = Math.floor(ageMs / 3600000);
|
|
25
|
+
const ageMins = Math.floor(ageMs / 60000);
|
|
26
|
+
const ageStr = ageHours > 0 ? `${ageHours}h ago` : `${ageMins}m ago`;
|
|
27
|
+
const sizeKB = Math.round(stat.size / 1024);
|
|
28
|
+
const isFresh = ageMs < 3600000; // < 1 hour
|
|
29
|
+
|
|
30
|
+
console.log(
|
|
31
|
+
isFresh
|
|
32
|
+
? chalk.green(` ✓ Context doc is fresh`)
|
|
33
|
+
: chalk.yellow(` ⚡ Context doc may be stale`)
|
|
34
|
+
);
|
|
35
|
+
console.log(chalk.gray(` Last updated: ${ageStr} | Size: ${sizeKB}KB`));
|
|
36
|
+
console.log(chalk.gray(` Path: ${contextPath}`));
|
|
37
|
+
|
|
38
|
+
// Scope status
|
|
39
|
+
console.log(chalk.bold("\n Scope:"));
|
|
40
|
+
if (config.active.length === 0) {
|
|
41
|
+
console.log(chalk.gray(" No active scope. Run cliper init to auto-detect."));
|
|
42
|
+
} else {
|
|
43
|
+
for (const p of config.active) console.log(chalk.white(` • ${p} `) + chalk.cyan("[active]"));
|
|
44
|
+
for (const p of config.watched) console.log(chalk.white(` • ${p} `) + chalk.blue("[watched]"));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Git status
|
|
48
|
+
const git = await getGitContext(projectRoot);
|
|
49
|
+
if (git.isGitRepo) {
|
|
50
|
+
console.log(chalk.bold("\n Git:"));
|
|
51
|
+
console.log(chalk.gray(` Branch: ${git.branch}`));
|
|
52
|
+
if (git.lastCommit) {
|
|
53
|
+
console.log(chalk.gray(` Latest: ${git.lastCommit.hash} — ${git.lastCommit.message} (${git.lastCommit.timeAgo})`));
|
|
54
|
+
}
|
|
55
|
+
if (git.uncommittedChanges.length > 0) {
|
|
56
|
+
console.log(chalk.yellow(` Uncommitted: ${git.uncommittedChanges.length} files changed`));
|
|
57
|
+
if (ageMs > 600000) {
|
|
58
|
+
// Context is older than 10 mins and there are uncommitted changes
|
|
59
|
+
console.log(chalk.yellow("\n ⚡ Changes detected since last context update."));
|
|
60
|
+
console.log(chalk.gray(" Run: cliper sync\n"));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log();
|
|
67
|
+
}
|