pirl-framework-cli 0.0.0-stage → 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/CHANGELOG.md +50 -0
- package/LICENSE +21 -0
- package/README.md +265 -2
- package/bundled-framework/files/.agents/evals/layer_record.py +438 -0
- package/bundled-framework/files/.agents/evals/tests/test_layer_record.py +264 -0
- package/bundled-framework/files/.agents/evals/tests/test_skills.py +210 -0
- package/bundled-framework/files/.agents/evals/tests/test_validate_layer_artifact.py +214 -0
- package/bundled-framework/files/.agents/evals/validate_layer_artifact.py +415 -0
- package/bundled-framework/files/.agents/instructions/implement.instructions.md +343 -0
- package/bundled-framework/files/.agents/instructions/layered-task-execution.instructions.md +370 -0
- package/bundled-framework/files/.agents/instructions/layout-verification.instructions.md +107 -0
- package/bundled-framework/files/.agents/instructions/plan.instructions.md +292 -0
- package/bundled-framework/files/.agents/instructions/review.instructions.md +236 -0
- package/bundled-framework/files/.agents/instructions/self-evaluation.instructions.md +146 -0
- package/bundled-framework/files/.agents/instructions/standalone-and-maintenance.instructions.md +230 -0
- package/bundled-framework/files/.agents/roles/backend-developer.role.md +45 -0
- package/bundled-framework/files/.agents/roles/business-analyst.role.md +55 -0
- package/bundled-framework/files/.agents/roles/code-analyst.role.md +63 -0
- package/bundled-framework/files/.agents/roles/code-reviewer.role.md +46 -0
- package/bundled-framework/files/.agents/roles/frontend-developer.role.md +51 -0
- package/bundled-framework/files/.agents/roles/process-auditor.role.md +62 -0
- package/bundled-framework/files/.agents/roles/solution-architect.role.md +53 -0
- package/bundled-framework/files/.agents/roles/technical-writer.role.md +68 -0
- package/bundled-framework/files/.agents/roles/ux-designer.role.md +46 -0
- package/bundled-framework/files/.skills/pirl-implement/SKILL.md +115 -0
- package/bundled-framework/files/.skills/pirl-implement/assets/implementation-record-template.md +22 -0
- package/bundled-framework/files/.skills/pirl-implement/references/implementation-record-format.md +41 -0
- package/bundled-framework/files/.skills/pirl-implement/scripts/add_implementation_record.py +58 -0
- package/bundled-framework/files/.skills/pirl-implement/scripts/new_layout_run.py +51 -0
- package/bundled-framework/files/.skills/pirl-plan/SKILL.md +100 -0
- package/bundled-framework/files/.skills/pirl-plan/assets/plan-template.md +48 -0
- package/bundled-framework/files/.skills/pirl-plan/references/plan-format.md +65 -0
- package/bundled-framework/files/.skills/pirl-plan/scripts/new_plan.py +93 -0
- package/bundled-framework/files/.skills/pirl-review/SKILL.md +106 -0
- package/bundled-framework/files/.skills/pirl-review/assets/review-template.md +92 -0
- package/bundled-framework/files/.skills/pirl-review/references/review-format.md +70 -0
- package/bundled-framework/files/.skills/pirl-review/scripts/new_review.py +83 -0
- package/bundled-framework/files/AGENTS.md +303 -0
- package/bundled-framework/files/CONTEXT.md +187 -0
- package/bundled-framework/files/LICENSE +21 -0
- package/bundled-framework/files/README.md +170 -0
- package/bundled-framework/files/gitignore.pirl-bundled +12 -0
- package/bundled-framework/manifest.json +45 -0
- package/dist/bin/main.js +128 -0
- package/dist/bin/pirl.js +6 -0
- package/dist/commands/dispatch.js +57 -0
- package/dist/commands/registry.js +39 -0
- package/dist/core/clipboard.js +70 -0
- package/dist/core/config.js +73 -0
- package/dist/core/exec.js +32 -0
- package/dist/core/paths.js +34 -0
- package/dist/core/targetRepo.js +22 -0
- package/dist/doctor/doctor.js +95 -0
- package/dist/engine/agentMerge.js +71 -0
- package/dist/engine/agents.js +67 -0
- package/dist/engine/attachments.js +251 -0
- package/dist/engine/claudeQuota.js +62 -0
- package/dist/engine/claudeRunner.js +118 -0
- package/dist/engine/claudeStream.js +205 -0
- package/dist/engine/hubData.js +176 -0
- package/dist/engine/permissions.js +67 -0
- package/dist/engine/pricing.js +24 -0
- package/dist/engine/protocol.js +98 -0
- package/dist/engine/run.js +527 -0
- package/dist/engine/runStore.js +67 -0
- package/dist/engine/types.js +3 -0
- package/dist/hub/framework.js +13 -0
- package/dist/hub/hub.js +125 -0
- package/dist/hub/services.js +38 -0
- package/dist/hub/update.js +261 -0
- package/dist/providers/claude.js +62 -0
- package/dist/providers/index.js +17 -0
- package/dist/providers/types.js +1 -0
- package/dist/ui/App.js +849 -0
- package/dist/ui/browse.js +144 -0
- package/dist/ui/components/Header.js +54 -0
- package/dist/ui/components/Input.js +76 -0
- package/dist/ui/components/Lines.js +50 -0
- package/dist/ui/components/Panels.js +73 -0
- package/dist/ui/content.js +225 -0
- package/dist/ui/frameworkConsole.js +541 -0
- package/dist/ui/inputEditor.js +86 -0
- package/dist/ui/layout.js +36 -0
- package/dist/ui/lines.js +15 -0
- package/dist/ui/listOverlay.js +97 -0
- package/dist/ui/runView.js +305 -0
- package/dist/ui/session.js +1 -0
- package/dist/ui/terminalInput.js +45 -0
- package/dist/ui/text.js +58 -0
- package/dist/ui/theme.js +139 -0
- package/dist/ui/ticker.js +48 -0
- package/package.json +67 -4
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* The layer runs of each target repository, kept in ~/.pirl/sessions/<repo>/ so /resume can continue any of them
|
|
6
|
+
* after the TUI was closed, the quota ran out or the network dropped. One file per run, named by its key; each holds
|
|
7
|
+
* the provider session id, never a credential. The newest KEEP runs of a repository are kept.
|
|
8
|
+
*/
|
|
9
|
+
const KEEP = 20;
|
|
10
|
+
const repoKey = (target) => createHash('sha1').update(target).digest('hex').slice(0, 16);
|
|
11
|
+
const dirFor = (home, target) => join(home, 'sessions', repoKey(target));
|
|
12
|
+
/** Before several runs were kept, a repository's last run was a single file; it becomes one entry of the list. */
|
|
13
|
+
const legacyFile = (home, target) => join(home, 'sessions', `${repoKey(target)}.json`);
|
|
14
|
+
const safeName = (key) => key.replace(/[^\w.-]/g, '_');
|
|
15
|
+
export function saveRun(home, target, run) {
|
|
16
|
+
const dir = dirFor(home, target);
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
18
|
+
const file = join(dir, `${safeName(run.key)}.json`);
|
|
19
|
+
writeFileSync(`${file}.tmp`, JSON.stringify({ target, ...run }, null, 2), 'utf8');
|
|
20
|
+
renameSync(`${file}.tmp`, file);
|
|
21
|
+
prune(dir);
|
|
22
|
+
}
|
|
23
|
+
function prune(dir) {
|
|
24
|
+
const files = readdirSync(dir)
|
|
25
|
+
.filter(name => name.endsWith('.json'))
|
|
26
|
+
.map(name => ({ name, time: statSync(join(dir, name)).mtimeMs }))
|
|
27
|
+
.sort((a, b) => b.time - a.time);
|
|
28
|
+
for (const file of files.slice(KEEP))
|
|
29
|
+
rmSync(join(dir, file.name), { force: true });
|
|
30
|
+
}
|
|
31
|
+
function migrateLegacy(home, target) {
|
|
32
|
+
const legacy = legacyFile(home, target);
|
|
33
|
+
if (!existsSync(legacy))
|
|
34
|
+
return;
|
|
35
|
+
try {
|
|
36
|
+
const data = JSON.parse(readFileSync(legacy, 'utf8'));
|
|
37
|
+
const dir = dirFor(home, target);
|
|
38
|
+
mkdirSync(dir, { recursive: true });
|
|
39
|
+
writeFileSync(join(dir, 'legacy.json'), JSON.stringify({ ...data, key: 'legacy' }, null, 2), 'utf8');
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
rmSync(legacy, { force: true });
|
|
43
|
+
}
|
|
44
|
+
/** The saved runs of `target`, newest first. */
|
|
45
|
+
export function listRuns(home, target) {
|
|
46
|
+
migrateLegacy(home, target);
|
|
47
|
+
const dir = dirFor(home, target);
|
|
48
|
+
if (!existsSync(dir))
|
|
49
|
+
return [];
|
|
50
|
+
const runs = [];
|
|
51
|
+
for (const name of readdirSync(dir).filter(file => file.endsWith('.json'))) {
|
|
52
|
+
try {
|
|
53
|
+
const data = JSON.parse(readFileSync(join(dir, name), 'utf8'));
|
|
54
|
+
if (data.target === target && data.kind && data.request)
|
|
55
|
+
runs.push({ ...data, key: data.key ?? name.replace(/\.json$/, ''), mtime: statSync(join(dir, name)).mtimeMs });
|
|
56
|
+
}
|
|
57
|
+
catch { }
|
|
58
|
+
}
|
|
59
|
+
return runs
|
|
60
|
+
.sort((a, b) => (Date.parse(b.savedAt ?? '') || b.mtime) - (Date.parse(a.savedAt ?? '') || a.mtime))
|
|
61
|
+
.map(({ mtime: _mtime, ...run }) => run);
|
|
62
|
+
}
|
|
63
|
+
export function clearRun(home, target, key) {
|
|
64
|
+
rmSync(join(dirFor(home, target), `${safeName(key)}.json`), { force: true });
|
|
65
|
+
}
|
|
66
|
+
/** A run /resume can continue: it was stopped, failed, or is waiting for answers, and has a provider session. */
|
|
67
|
+
export const resumable = (run) => !!run && !!run.sessionId && (run.status === 'stopped' || run.status === 'failed' || run.status === 'waiting' || run.status === 'running');
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export const LAYER_LABELS = { plan: 'Plan', implement: 'Implement', review: 'Review', chat: 'Chat' };
|
|
2
|
+
export const emptyUsage = () => ({ calls: 0, requests: 0, inputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, outputTokens: 0, costUsd: 0, models: [] });
|
|
3
|
+
export const totalTokens = (usage) => usage.inputTokens + usage.cacheWriteTokens + usage.cacheReadTokens + usage.outputTokens;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/** Where a framework file is stored inside the package; mirrors `storedPath` in scripts/bundle-framework.mjs. */
|
|
4
|
+
export const storedPath = (path) => path.replace(/(^|\/)\.gitignore$/, '$1gitignore.pirl-bundled');
|
|
5
|
+
export function loadBundledFramework(packageRoot) {
|
|
6
|
+
const dir = join(packageRoot, 'bundled-framework');
|
|
7
|
+
const manifestPath = join(dir, 'manifest.json');
|
|
8
|
+
if (!existsSync(manifestPath)) {
|
|
9
|
+
throw new Error(`Thiếu ${manifestPath}. Chạy \`npm run bundle-framework\` trước.`);
|
|
10
|
+
}
|
|
11
|
+
return { manifest: JSON.parse(readFileSync(manifestPath, 'utf8')), filesDir: join(dir, 'files') };
|
|
12
|
+
}
|
|
13
|
+
export const frameworkTag = (version) => `fw-${version}`;
|
package/dist/hub/hub.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { exec as defaultExec, succeeded } from '../core/exec.js';
|
|
4
|
+
import { frameworkTag, storedPath } from './framework.js';
|
|
5
|
+
/**
|
|
6
|
+
* The Hub is a local Git repository with no remote:
|
|
7
|
+
* - `upstream` holds the framework versions as shipped (one commit and one `fw-<version>` tag per version);
|
|
8
|
+
* - `main` is `upstream` plus the user's own improvements, and is the branch the layers run on.
|
|
9
|
+
* A CLI update never writes here; only `/framework` brings a newer bundled version in (src/hub/update.ts).
|
|
10
|
+
*/
|
|
11
|
+
export const UPSTREAM = 'upstream';
|
|
12
|
+
export const MAIN = 'main';
|
|
13
|
+
/** Git settings for every Hub command: content stored byte for byte, and no signing prompt from the user's config. */
|
|
14
|
+
export const GIT_FLAGS = ['-c', 'core.autocrlf=false', '-c', 'core.safecrlf=false', '-c', 'commit.gpgsign=false', '-c', 'tag.gpgsign=false'];
|
|
15
|
+
/** Baseline commits on `upstream` are written by the tool, not by the user. */
|
|
16
|
+
export const TOOL_AUTHOR = ['-c', 'user.name=PIRL CLI', '-c', 'user.email=pirl-cli@localhost'];
|
|
17
|
+
export class HubError extends Error {
|
|
18
|
+
}
|
|
19
|
+
export async function git(exec, cwd, args, extra = []) {
|
|
20
|
+
const result = await exec('git', [...GIT_FLAGS, ...extra, ...args], { cwd, timeoutMs: 60_000 });
|
|
21
|
+
if (!succeeded(result)) {
|
|
22
|
+
throw new HubError(`git ${args.join(' ')}: ${(result.error ?? result.stderr).trim() || `exit ${result.code}`}`);
|
|
23
|
+
}
|
|
24
|
+
return result.stdout;
|
|
25
|
+
}
|
|
26
|
+
async function isPirlHub(exec, path) {
|
|
27
|
+
if (!existsSync(join(path, '.git')))
|
|
28
|
+
return false;
|
|
29
|
+
const result = await exec('git', ['config', '--local', '--get', 'pirl.hub'], { cwd: path, timeoutMs: 10_000 });
|
|
30
|
+
return succeeded(result) && result.stdout.trim() === 'true';
|
|
31
|
+
}
|
|
32
|
+
/** Creates the Hub from the bundled framework on first use; never touches a directory that is not a PIRL Hub. */
|
|
33
|
+
export async function ensureHub(path, bundle, exec = defaultExec) {
|
|
34
|
+
if (existsSync(path)) {
|
|
35
|
+
if (!(await isPirlHub(exec, path))) {
|
|
36
|
+
if (readdirSync(path).length > 0) {
|
|
37
|
+
throw new HubError(`${path} đã tồn tại nhưng không phải Hub của PIRL CLI; CLI không thay đổi thư mục này.`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
return { created: false, status: await hubStatus(path, bundle, exec) };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Build in a sibling directory and rename at the end, so an interrupted first run leaves no half-made Hub.
|
|
45
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
46
|
+
const staging = `${path}.creating-${process.pid}`;
|
|
47
|
+
rmSync(staging, { recursive: true, force: true });
|
|
48
|
+
mkdirSync(staging, { recursive: true });
|
|
49
|
+
try {
|
|
50
|
+
await git(exec, staging, ['init', '--quiet']);
|
|
51
|
+
await git(exec, staging, ['symbolic-ref', 'HEAD', `refs/heads/${UPSTREAM}`]);
|
|
52
|
+
await git(exec, staging, ['config', 'core.autocrlf', 'false']);
|
|
53
|
+
for (const file of Object.keys(bundle.manifest.files)) {
|
|
54
|
+
const to = join(staging, file);
|
|
55
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
56
|
+
cpSync(join(bundle.filesDir, storedPath(file)), to);
|
|
57
|
+
}
|
|
58
|
+
await git(exec, staging, ['add', '--all']);
|
|
59
|
+
const tag = frameworkTag(bundle.manifest.version);
|
|
60
|
+
await git(exec, staging, ['commit', '--quiet', '-m', `Framework ${tag}`], TOOL_AUTHOR);
|
|
61
|
+
await git(exec, staging, ['tag', tag]);
|
|
62
|
+
await git(exec, staging, ['branch', MAIN]);
|
|
63
|
+
await git(exec, staging, ['checkout', '--quiet', MAIN]);
|
|
64
|
+
// Marked last: a directory carries the marker only once it is a complete Hub.
|
|
65
|
+
await git(exec, staging, ['config', 'pirl.hub', 'true']);
|
|
66
|
+
if (existsSync(path))
|
|
67
|
+
rmSync(path, { recursive: true, force: true }); // only an empty directory reaches here
|
|
68
|
+
renameSync(staging, path);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
rmSync(staging, { recursive: true, force: true });
|
|
72
|
+
throw error instanceof HubError ? error : new HubError(String(error));
|
|
73
|
+
}
|
|
74
|
+
return { created: true, status: await hubStatus(path, bundle, exec) };
|
|
75
|
+
}
|
|
76
|
+
export function parseLsTree(output) {
|
|
77
|
+
const files = {};
|
|
78
|
+
for (const entry of output.split('\0')) {
|
|
79
|
+
const tab = entry.indexOf('\t');
|
|
80
|
+
if (tab < 0)
|
|
81
|
+
continue;
|
|
82
|
+
const [, type, hash] = entry.slice(0, tab).split(' ');
|
|
83
|
+
if (type === 'blob' && hash)
|
|
84
|
+
files[entry.slice(tab + 1)] = hash;
|
|
85
|
+
}
|
|
86
|
+
return files;
|
|
87
|
+
}
|
|
88
|
+
/** `git status --porcelain -z`: "XY path" entries; a rename or copy is followed by its original path. */
|
|
89
|
+
export function parsePorcelain(output) {
|
|
90
|
+
const entries = output.split('\0');
|
|
91
|
+
const paths = [];
|
|
92
|
+
for (let i = 0; i < entries.length; i++) {
|
|
93
|
+
const entry = entries[i];
|
|
94
|
+
if (entry.length < 4)
|
|
95
|
+
continue;
|
|
96
|
+
paths.push(entry.slice(3));
|
|
97
|
+
if (entry[0] === 'R' || entry[0] === 'C')
|
|
98
|
+
i++;
|
|
99
|
+
}
|
|
100
|
+
return paths;
|
|
101
|
+
}
|
|
102
|
+
export function sameFiles(a, b) {
|
|
103
|
+
const keys = Object.keys(a);
|
|
104
|
+
return keys.length === Object.keys(b).length && keys.every(key => a[key] === b[key]);
|
|
105
|
+
}
|
|
106
|
+
export async function hubStatus(path, bundle, exec = defaultExec) {
|
|
107
|
+
// The original version in use is the latest one merged into `main`, not the newest one staged on `upstream`.
|
|
108
|
+
const tagResult = await exec('git', ['describe', '--tags', '--abbrev=0', '--match', 'fw-*', MAIN], { cwd: path, timeoutMs: 10_000 });
|
|
109
|
+
const upstreamFiles = parseLsTree(await git(exec, path, ['ls-tree', '-r', '-z', UPSTREAM]));
|
|
110
|
+
// Update merges are not improvements of the user's.
|
|
111
|
+
const ahead = Number((await git(exec, path, ['rev-list', '--count', '--no-merges', `${UPSTREAM}..${MAIN}`])).trim());
|
|
112
|
+
const uncommitted = parsePorcelain(await git(exec, path, ['status', '--porcelain', '-z']));
|
|
113
|
+
const branchResult = await exec('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: path, timeoutMs: 10_000 });
|
|
114
|
+
// After an undone update, `upstream` already holds the newer version but `main` does not.
|
|
115
|
+
const merged = succeeded(await exec('git', ['merge-base', '--is-ancestor', UPSTREAM, MAIN], { cwd: path, timeoutMs: 10_000 }));
|
|
116
|
+
return {
|
|
117
|
+
path,
|
|
118
|
+
upstreamTag: succeeded(tagResult) ? tagResult.stdout.trim() : null,
|
|
119
|
+
localImprovements: Number.isFinite(ahead) ? ahead : 0,
|
|
120
|
+
uncommitted,
|
|
121
|
+
updateAvailable: !sameFiles(upstreamFiles, bundle.manifest.files) || !merged,
|
|
122
|
+
bundledVersion: bundle.manifest.version,
|
|
123
|
+
branch: succeeded(branchResult) ? branchResult.stdout.trim() : null,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { proposeMerge } from '../engine/agentMerge.js';
|
|
4
|
+
import { discardImprovements } from '../engine/hubData.js';
|
|
5
|
+
import { hubStatus } from './hub.js';
|
|
6
|
+
import * as update from './update.js';
|
|
7
|
+
/** `/framework` on the real Hub (src/hub/update.ts); the agent merge goes through the user's provider. */
|
|
8
|
+
export function frameworkServices(path, bundle, runner, modelOf) {
|
|
9
|
+
return {
|
|
10
|
+
status: () => hubStatus(path, bundle),
|
|
11
|
+
changes: () => update.localChanges(path),
|
|
12
|
+
diff: file => update.fileDiff(path, file),
|
|
13
|
+
textDiff: (from, to) => update.textDiff(from, to),
|
|
14
|
+
history: () => update.improvementHistory(path),
|
|
15
|
+
restore: file => update.restoreFile(path, file),
|
|
16
|
+
lastUpdate: () => update.lastUpdate(path),
|
|
17
|
+
undo: tag => update.undoUpdate(path, tag),
|
|
18
|
+
commitUncommitted: files => update.commitUncommitted(path, files),
|
|
19
|
+
discardUncommitted: async (files) => {
|
|
20
|
+
const result = await discardImprovements(path, files);
|
|
21
|
+
if (!result.ok)
|
|
22
|
+
throw new Error(result.error ?? 'Không bỏ được các thay đổi.');
|
|
23
|
+
},
|
|
24
|
+
startUpdate: () => update.startUpdate(path, bundle),
|
|
25
|
+
resolve: (draft, file, choice) => update.resolveConflict(draft.dir, file, choice),
|
|
26
|
+
readDraft: (draft, file) => {
|
|
27
|
+
try {
|
|
28
|
+
return readFileSync(join(draft.dir, file), 'utf8');
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
proposeMerge: (draft, conflict, config) => proposeMerge(runner, draft.dir, conflict, modelOf(config)),
|
|
35
|
+
finishUpdate: (draft, python) => update.finishUpdate(path, draft, python),
|
|
36
|
+
abortUpdate: () => update.removeDraft(path),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { exec as defaultExec, succeeded } from '../core/exec.js';
|
|
5
|
+
import { frameworkTag, storedPath } from './framework.js';
|
|
6
|
+
import { GIT_FLAGS, git, HubError, MAIN, parseLsTree, parsePorcelain, sameFiles, TOOL_AUTHOR, UPSTREAM } from './hub.js';
|
|
7
|
+
/**
|
|
8
|
+
* What `/framework` does to the Hub (proposal section 9). `main` is never left half-updated: an update is merged in a
|
|
9
|
+
* draft worktree on its own branch, checked with the framework's tests there, and only then fast-forwarded onto
|
|
10
|
+
* `main`, with a `pre-update/<version>` tag on the old position so it can be undone.
|
|
11
|
+
*/
|
|
12
|
+
export const DRAFT_BRANCH = 'pirl-update';
|
|
13
|
+
const PRE_UPDATE = 'pre-update/';
|
|
14
|
+
/** Where the draft worktree lives: next to the Hub, never inside it. */
|
|
15
|
+
export const draftDir = (hub) => `${hub}-update`;
|
|
16
|
+
const run = (exec, cwd, args, extra = []) => exec('git', [...GIT_FLAGS, ...extra, ...args], { cwd, timeoutMs: 60_000 });
|
|
17
|
+
/** The Hub must be on `main` with nothing uncommitted before anything here rewrites its files. */
|
|
18
|
+
export async function assertClean(hub, exec = defaultExec) {
|
|
19
|
+
const branch = (await git(exec, hub, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).trim();
|
|
20
|
+
if (branch !== MAIN)
|
|
21
|
+
throw new HubError(`Hub đang ở nhánh ${branch}, không phải ${MAIN}.`);
|
|
22
|
+
const dirty = parsePorcelain(await git(exec, hub, ['status', '--porcelain', '-z']));
|
|
23
|
+
if (dirty.length > 0)
|
|
24
|
+
throw new HubError(`Hub có ${dirty.length} file chưa commit: ${dirty.slice(0, 5).join(', ')}. Commit hoặc bỏ chúng trước.`);
|
|
25
|
+
}
|
|
26
|
+
/** Framework files on `main` that differ from the original on `upstream`: the user's local improvements. */
|
|
27
|
+
export async function localChanges(hub, exec = defaultExec) {
|
|
28
|
+
const names = (await git(exec, hub, ['diff', '--name-status', '-z', '--no-renames', UPSTREAM, MAIN])).split('\0');
|
|
29
|
+
const stats = (await git(exec, hub, ['diff', '--numstat', '-z', '--no-renames', UPSTREAM, MAIN])).split('\0');
|
|
30
|
+
const counts = new Map();
|
|
31
|
+
for (const entry of stats) {
|
|
32
|
+
const [added, removed, path] = entry.split('\t');
|
|
33
|
+
if (path)
|
|
34
|
+
counts.set(path, { added: Number(added) || 0, removed: Number(removed) || 0 });
|
|
35
|
+
}
|
|
36
|
+
const changes = [];
|
|
37
|
+
for (let i = 0; i + 1 < names.length; i += 2) {
|
|
38
|
+
const code = names[i];
|
|
39
|
+
const path = names[i + 1];
|
|
40
|
+
if (!code || !path)
|
|
41
|
+
continue;
|
|
42
|
+
changes.push({ path, status: code === 'A' ? 'added' : code === 'D' ? 'deleted' : 'modified', ...(counts.get(path) ?? { added: 0, removed: 0 }) });
|
|
43
|
+
}
|
|
44
|
+
return changes;
|
|
45
|
+
}
|
|
46
|
+
/** The unified diff of one file, from the original framework to the Hub's version. */
|
|
47
|
+
export async function fileDiff(hub, path, exec = defaultExec) {
|
|
48
|
+
return git(exec, hub, ['diff', '--no-color', '--no-renames', UPSTREAM, MAIN, '--', path]);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Commits on `main` that are not on `upstream`, newest first: improvements, restores and update merges.
|
|
52
|
+
* With `path`, only the commits that changed that file.
|
|
53
|
+
*/
|
|
54
|
+
export async function improvementHistory(hub, exec = defaultExec, range = `${UPSTREAM}..${MAIN}`, path) {
|
|
55
|
+
const format = '%H%x1f%cI%x1f%s%x1f%(trailers:key=Pirl-Record,valueonly,separator=%x2c)%x1f%(trailers:key=Pirl-Source,valueonly,separator=%x2c)%x1e';
|
|
56
|
+
const output = await git(exec, hub, ['log', ...(path ? [] : ['--first-parent']), `--format=${format}`, range, ...(path ? ['--', path] : [])]);
|
|
57
|
+
return output
|
|
58
|
+
.split('\x1e')
|
|
59
|
+
.map(entry => entry.trim())
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.map(entry => {
|
|
62
|
+
const [hash = '', date = '', subject = '', record = '', source = ''] = entry.split('\x1f');
|
|
63
|
+
return { hash, date, subject, record: record.trim() || null, source: source.trim() || null };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** One commit on `main` that puts a file back as the original framework has it (or removes a file the original lacks). */
|
|
67
|
+
export async function restoreFile(hub, path, exec = defaultExec) {
|
|
68
|
+
await assertClean(hub, exec);
|
|
69
|
+
const inUpstream = succeeded(await run(exec, hub, ['cat-file', '-e', `${UPSTREAM}:${path}`]));
|
|
70
|
+
if (inUpstream)
|
|
71
|
+
await git(exec, hub, ['checkout', UPSTREAM, '--', path]);
|
|
72
|
+
else
|
|
73
|
+
await git(exec, hub, ['rm', '--quiet', '--', path]);
|
|
74
|
+
const message = [`Restore ${path} to the original framework`, '', 'Pirl-Source: restore'].join('\n');
|
|
75
|
+
await git(exec, hub, ['commit', '--quiet', '-m', message, '--', path], await identity(hub, exec));
|
|
76
|
+
}
|
|
77
|
+
/** The user's Git identity when it is set; otherwise a local one, so a commit never stops on a missing name. */
|
|
78
|
+
async function identity(hub, exec) {
|
|
79
|
+
const name = await exec('git', ['config', 'user.name'], { cwd: hub, timeoutMs: 10_000 });
|
|
80
|
+
const email = await exec('git', ['config', 'user.email'], { cwd: hub, timeoutMs: 10_000 });
|
|
81
|
+
return succeeded(name) && name.stdout.trim() && succeeded(email) && email.stdout.trim() ? [] : ['-c', 'user.name=PIRL CLI user', '-c', 'user.email=pirl-cli@localhost'];
|
|
82
|
+
}
|
|
83
|
+
/** The latest update that can be undone: a `pre-update/*` tag whose commit is still part of `main`. */
|
|
84
|
+
export async function lastUpdate(hub, exec = defaultExec) {
|
|
85
|
+
const tags = (await git(exec, hub, ['tag', '--list', `${PRE_UPDATE}*`, '--sort=-creatordate'])).split('\n').map(tag => tag.trim()).filter(Boolean);
|
|
86
|
+
for (const tag of tags) {
|
|
87
|
+
if (!succeeded(await run(exec, hub, ['merge-base', '--is-ancestor', tag, MAIN])))
|
|
88
|
+
continue;
|
|
89
|
+
const commits = await improvementHistory(hub, exec, `${tag}..${MAIN}`);
|
|
90
|
+
if (commits.length === 0)
|
|
91
|
+
continue;
|
|
92
|
+
return { tag, version: tag.slice(PRE_UPDATE.length).replace(/-\d+$/, ''), commits };
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/** Puts `main` back where it was before the update; every commit made since then goes too, as the user confirmed. */
|
|
97
|
+
export async function undoUpdate(hub, tag, exec = defaultExec) {
|
|
98
|
+
await assertClean(hub, exec);
|
|
99
|
+
await git(exec, hub, ['reset', '--hard', '--quiet', tag]);
|
|
100
|
+
await git(exec, hub, ['tag', '--delete', tag]);
|
|
101
|
+
}
|
|
102
|
+
// ---------- update ----------
|
|
103
|
+
/** Puts the bundled framework on `upstream` as a new commit and tag; nothing changes when it is already there. */
|
|
104
|
+
export async function stageBundle(hub, bundle, exec = defaultExec) {
|
|
105
|
+
const tag = frameworkTag(bundle.manifest.version);
|
|
106
|
+
const upstreamTree = await git(exec, hub, ['ls-tree', '-r', '-z', UPSTREAM]);
|
|
107
|
+
if (sameFiles(parseLsTree(upstreamTree), bundle.manifest.files))
|
|
108
|
+
return tag;
|
|
109
|
+
const modes = new Map();
|
|
110
|
+
for (const entry of upstreamTree.split('\0')) {
|
|
111
|
+
const tab = entry.indexOf('\t');
|
|
112
|
+
if (tab > 0)
|
|
113
|
+
modes.set(entry.slice(tab + 1), entry.slice(0, tab).split(' ')[0]);
|
|
114
|
+
}
|
|
115
|
+
// A private index builds the new tree without touching the Hub's index or working tree.
|
|
116
|
+
const scratch = mkdtempSync(join(tmpdir(), 'pirl-index-'));
|
|
117
|
+
const env = { ...process.env, GIT_INDEX_FILE: join(scratch, 'index') };
|
|
118
|
+
try {
|
|
119
|
+
const lines = [];
|
|
120
|
+
for (const [path, expected] of Object.entries(bundle.manifest.files)) {
|
|
121
|
+
const stored = await exec('git', ['hash-object', '-w', '--no-filters', join(bundle.filesDir, storedPath(path))], { cwd: hub, timeoutMs: 30_000 });
|
|
122
|
+
const hash = stored.stdout.trim();
|
|
123
|
+
if (!succeeded(stored) || hash !== expected)
|
|
124
|
+
throw new HubError(`Framework đi kèm CLI bị hỏng ở ${path}: mã băm không khớp manifest.`);
|
|
125
|
+
lines.push(`${modes.get(path) ?? '100644'} ${hash}\t${path}`);
|
|
126
|
+
}
|
|
127
|
+
const index = await exec('git', ['update-index', '--add', '--index-info'], { cwd: hub, env, input: `${lines.join('\n')}\n`, timeoutMs: 60_000 });
|
|
128
|
+
if (!succeeded(index))
|
|
129
|
+
throw new HubError(`git update-index: ${index.stderr.trim()}`);
|
|
130
|
+
const tree = await exec('git', ['write-tree'], { cwd: hub, env, timeoutMs: 30_000 });
|
|
131
|
+
if (!succeeded(tree))
|
|
132
|
+
throw new HubError(`git write-tree: ${tree.stderr.trim()}`);
|
|
133
|
+
const parent = (await git(exec, hub, ['rev-parse', UPSTREAM])).trim();
|
|
134
|
+
const commit = (await git(exec, hub, ['commit-tree', tree.stdout.trim(), '-p', parent, '-m', `Framework ${tag}`], TOOL_AUTHOR)).trim();
|
|
135
|
+
await git(exec, hub, ['update-ref', `refs/heads/${UPSTREAM}`, commit, parent]);
|
|
136
|
+
await git(exec, hub, ['tag', '--force', tag, commit]);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
return tag;
|
|
142
|
+
}
|
|
143
|
+
/** Removes a draft left by an update that did not finish. */
|
|
144
|
+
export async function removeDraft(hub, exec = defaultExec) {
|
|
145
|
+
const dir = draftDir(hub);
|
|
146
|
+
await run(exec, hub, ['worktree', 'remove', '--force', dir]);
|
|
147
|
+
rmSync(dir, { recursive: true, force: true });
|
|
148
|
+
await run(exec, hub, ['worktree', 'prune']);
|
|
149
|
+
await run(exec, hub, ['branch', '-D', DRAFT_BRANCH]);
|
|
150
|
+
}
|
|
151
|
+
const show = async (exec, cwd, spec) => {
|
|
152
|
+
const result = await run(exec, cwd, ['show', spec]);
|
|
153
|
+
return succeeded(result) ? result.stdout : null;
|
|
154
|
+
};
|
|
155
|
+
/** Merges the bundled framework into a draft of `main`; conflicts wait in the draft for the user's choices. */
|
|
156
|
+
export async function startUpdate(hub, bundle, exec = defaultExec) {
|
|
157
|
+
await assertClean(hub, exec);
|
|
158
|
+
const tag = await stageBundle(hub, bundle, exec);
|
|
159
|
+
await removeDraft(hub, exec);
|
|
160
|
+
const dir = draftDir(hub);
|
|
161
|
+
await git(exec, hub, ['worktree', 'add', '--quiet', '-b', DRAFT_BRANCH, dir, MAIN]);
|
|
162
|
+
const changed = (await git(exec, hub, ['diff', '--name-only', '--no-renames', `${MAIN}...${UPSTREAM}`])).split('\n').filter(Boolean);
|
|
163
|
+
const merge = await run(exec, dir, ['merge', '--no-ff', '--no-commit', UPSTREAM], [...TOOL_AUTHOR, '-c', 'merge.conflictStyle=diff3']);
|
|
164
|
+
const unmerged = (await git(exec, dir, ['diff', '--name-only', '--diff-filter=U'])).split('\n').filter(Boolean);
|
|
165
|
+
if (!succeeded(merge) && unmerged.length === 0) {
|
|
166
|
+
await removeDraft(hub, exec);
|
|
167
|
+
throw new HubError(`git merge: ${(merge.stderr || merge.stdout).trim()}`);
|
|
168
|
+
}
|
|
169
|
+
const conflicts = [];
|
|
170
|
+
for (const path of unmerged) {
|
|
171
|
+
conflicts.push({
|
|
172
|
+
path,
|
|
173
|
+
base: await show(exec, dir, `:1:${path}`),
|
|
174
|
+
mine: await show(exec, dir, `:2:${path}`),
|
|
175
|
+
theirs: await show(exec, dir, `:3:${path}`),
|
|
176
|
+
myCommits: await improvementHistory(hub, exec, `${UPSTREAM}..${MAIN}`, path),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return { dir, tag, changed, conflicts };
|
|
180
|
+
}
|
|
181
|
+
export const hasConflictMarkers = (text) => /^(<<<<<<<|>>>>>>>|\|\|\|\|\|\|\|)( |$)/m.test(text);
|
|
182
|
+
/** Settles one conflict in the draft: the user's version, the new original, or given content (null removes the file). */
|
|
183
|
+
export async function resolveConflict(dir, path, choice, exec = defaultExec) {
|
|
184
|
+
if (choice === 'mine' || choice === 'theirs') {
|
|
185
|
+
const present = succeeded(await run(exec, dir, ['cat-file', '-e', `:${choice === 'mine' ? 2 : 3}:${path}`]));
|
|
186
|
+
if (present) {
|
|
187
|
+
await git(exec, dir, ['checkout', `--${choice === 'mine' ? 'ours' : 'theirs'}`, '--', path]);
|
|
188
|
+
await git(exec, dir, ['add', '--', path]);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
await git(exec, dir, ['rm', '--quiet', '--', path]);
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (choice.content === null) {
|
|
196
|
+
await git(exec, dir, ['rm', '--quiet', '--force', '--', path]);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (hasConflictMarkers(choice.content))
|
|
200
|
+
throw new HubError(`${path} vẫn còn dấu xung đột (<<<<<<<, >>>>>>>).`);
|
|
201
|
+
writeFileSync(join(dir, path), choice.content, 'utf8');
|
|
202
|
+
await git(exec, dir, ['add', '--', path]);
|
|
203
|
+
}
|
|
204
|
+
/** Paths still unmerged in the draft. */
|
|
205
|
+
export async function unresolved(dir, exec = defaultExec) {
|
|
206
|
+
return (await git(exec, dir, ['diff', '--name-only', '--diff-filter=U'])).split('\n').filter(Boolean);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Checks the merged draft with the framework's own tests, then moves `main` onto it and tags the old position.
|
|
210
|
+
* On any failure the draft is dropped and `main` is left exactly as it was.
|
|
211
|
+
*/
|
|
212
|
+
export async function finishUpdate(hub, draft, python, exec = defaultExec) {
|
|
213
|
+
const fail = async (error, output) => {
|
|
214
|
+
await removeDraft(hub, exec);
|
|
215
|
+
return { ok: false, error, output };
|
|
216
|
+
};
|
|
217
|
+
try {
|
|
218
|
+
const left = await unresolved(draft.dir, exec);
|
|
219
|
+
if (left.length > 0)
|
|
220
|
+
return { ok: false, error: `Còn ${left.length} xung đột chưa giải quyết: ${left.join(', ')}.` };
|
|
221
|
+
if (!python)
|
|
222
|
+
return { ok: false, error: 'Không tìm thấy Python để chạy test của framework; cập nhật chưa được áp dụng.' };
|
|
223
|
+
const tests = await exec(python.command, [...python.args, '-B', '-m', 'unittest', 'discover', '-s', '.agents/evals/tests', '-p', 'test_*.py'], { cwd: draft.dir, timeoutMs: 300_000 });
|
|
224
|
+
if (!succeeded(tests))
|
|
225
|
+
return fail('Test của framework không qua trên bản nháp; Hub giữ nguyên.', `${tests.stderr}\n${tests.stdout}`.trim());
|
|
226
|
+
const message = [`Update framework to ${draft.tag}`, '', 'Pirl-Source: update'].join('\n');
|
|
227
|
+
await git(exec, draft.dir, ['commit', '--quiet', '--no-verify', '-m', message], TOOL_AUTHOR);
|
|
228
|
+
await assertClean(hub, exec);
|
|
229
|
+
let preTag = `${PRE_UPDATE}${draft.tag}`;
|
|
230
|
+
for (let n = 2; succeeded(await run(exec, hub, ['rev-parse', '--verify', '--quiet', `refs/tags/${preTag}`])); n++)
|
|
231
|
+
preTag = `${PRE_UPDATE}${draft.tag}-${n}`;
|
|
232
|
+
await git(exec, hub, ['tag', preTag, MAIN]);
|
|
233
|
+
await git(exec, hub, ['merge', '--ff-only', '--quiet', DRAFT_BRANCH]);
|
|
234
|
+
await removeDraft(hub, exec);
|
|
235
|
+
return { ok: true, tag: draft.tag, preTag };
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
export const draftExists = (hub) => existsSync(draftDir(hub));
|
|
242
|
+
/** A unified diff between two texts (null is an absent file), for showing what each side of a conflict changed. */
|
|
243
|
+
export async function textDiff(from, to, exec = defaultExec) {
|
|
244
|
+
const dir = mkdtempSync(join(tmpdir(), 'pirl-diff-'));
|
|
245
|
+
try {
|
|
246
|
+
writeFileSync(join(dir, 'a'), from ?? '', 'utf8');
|
|
247
|
+
writeFileSync(join(dir, 'b'), to ?? '', 'utf8');
|
|
248
|
+
// Exit code 1 means the texts differ; the diff is still on stdout.
|
|
249
|
+
const result = await exec('git', ['diff', '--no-index', '--no-color', 'a', 'b'], { cwd: dir, timeoutMs: 30_000 });
|
|
250
|
+
return result.stdout;
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
rmSync(dir, { recursive: true, force: true });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** Commits changes the user made to the Hub by hand, before an update or a restore needs a clean Hub. */
|
|
257
|
+
export async function commitUncommitted(hub, files, exec = defaultExec) {
|
|
258
|
+
await git(exec, hub, ['add', '--all', '--', ...files]);
|
|
259
|
+
const message = ['Manual framework change', '', 'Pirl-Source: manual'].join('\n');
|
|
260
|
+
await git(exec, hub, ['commit', '--quiet', '-m', message, '--', ...files], await identity(hub, exec));
|
|
261
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { succeeded } from '../core/exec.js';
|
|
2
|
+
const AUTH_LABELS = {
|
|
3
|
+
oauth_token: 'tài khoản Claude (subscription)',
|
|
4
|
+
claude_ai: 'tài khoản Claude (subscription)',
|
|
5
|
+
api_key: 'API key',
|
|
6
|
+
};
|
|
7
|
+
export function parseAuthStatus(stdout) {
|
|
8
|
+
try {
|
|
9
|
+
const data = JSON.parse(stdout);
|
|
10
|
+
const method = typeof data.authMethod === 'string' ? data.authMethod : undefined;
|
|
11
|
+
const viaCloud = typeof data.apiProvider === 'string' && data.apiProvider !== 'firstParty' ? data.apiProvider : undefined;
|
|
12
|
+
return {
|
|
13
|
+
loggedIn: data.loggedIn === true,
|
|
14
|
+
authMethod: viaCloud ?? (method ? (AUTH_LABELS[method] ?? method) : undefined),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return { loggedIn: null };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** `--agents` read from a file needs 2.1.281; `--permission-prompts` needs 2.1.259. */
|
|
22
|
+
export const MIN_CLAUDE_VERSION = '2.1.281';
|
|
23
|
+
export function versionAtLeast(version, minimum) {
|
|
24
|
+
const parse = (text) => text.split('.').map(part => Number.parseInt(part, 10) || 0);
|
|
25
|
+
const [a, b] = [parse(version), parse(minimum)];
|
|
26
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
27
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0))
|
|
28
|
+
return (a[i] ?? 0) > (b[i] ?? 0);
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
export const claudeProvider = {
|
|
33
|
+
id: 'claude',
|
|
34
|
+
label: 'Claude Code',
|
|
35
|
+
available: true,
|
|
36
|
+
async detect(exec) {
|
|
37
|
+
const version = await exec('claude', ['--version'], { timeoutMs: 15_000 });
|
|
38
|
+
if (!succeeded(version)) {
|
|
39
|
+
return {
|
|
40
|
+
installed: false,
|
|
41
|
+
loggedIn: null,
|
|
42
|
+
fix: 'Cài Claude Code theo hướng dẫn tại https://code.claude.com/docs, rồi chạy `claude` một lần để đăng nhập.',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// `claude auth status` reports sign-in state as JSON without exposing any credential.
|
|
46
|
+
const status = await exec('claude', ['auth', 'status', '--json'], { timeoutMs: 15_000 });
|
|
47
|
+
const auth = succeeded(status) || status.stdout.trim() ? parseAuthStatus(status.stdout) : { loggedIn: null };
|
|
48
|
+
const found = version.stdout.trim().split(/\s+/)[0] ?? '';
|
|
49
|
+
const outdated = found !== '' && !versionAtLeast(found, MIN_CLAUDE_VERSION);
|
|
50
|
+
return {
|
|
51
|
+
installed: true,
|
|
52
|
+
version: found,
|
|
53
|
+
...auth,
|
|
54
|
+
outdated,
|
|
55
|
+
fix: auth.loggedIn === false
|
|
56
|
+
? 'Chạy `claude` (hoặc `claude auth login`) để đăng nhập bằng tài khoản của bạn.'
|
|
57
|
+
: outdated
|
|
58
|
+
? `PIRL cần Claude Code ${MIN_CLAUDE_VERSION} trở lên. Chạy \`claude update\`.`
|
|
59
|
+
: undefined,
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { claudeProvider } from './claude.js';
|
|
2
|
+
const planned = (id, label) => ({
|
|
3
|
+
id,
|
|
4
|
+
label,
|
|
5
|
+
available: false,
|
|
6
|
+
detect: async () => ({ installed: false, loggedIn: null, fix: `${label} sẽ được hỗ trợ ở giai đoạn sau.` }),
|
|
7
|
+
});
|
|
8
|
+
/** Claude Code is the pilot provider; the others are listed so /settings can show what is coming. */
|
|
9
|
+
export const PROVIDERS = [
|
|
10
|
+
claudeProvider,
|
|
11
|
+
planned('codex', 'Codex CLI'),
|
|
12
|
+
planned('gemini', 'Gemini CLI'),
|
|
13
|
+
planned('copilot', 'Copilot CLI'),
|
|
14
|
+
];
|
|
15
|
+
export function getProvider(id) {
|
|
16
|
+
return PROVIDERS.find(provider => provider.id === id) ?? claudeProvider;
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|