gent-cli 7.0.0 → 9.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gent-cli",
3
- "version": "7.0.0",
4
- "description": "A modern, Git-like version control CLI with built-in cloud authentication and global user identity management.",
3
+ "version": "9.0.0",
4
+ "description": "A modern, Git-like version control CLI with cloud sync, AI-powered superpowers (ask/review/docs/changelog), and zero-friction setup (gent setup/doctor/config).",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "gent": "src/index.js"
@@ -0,0 +1,82 @@
1
+ /**
2
+ * AI Command - Manage and verify AI integration.
3
+ *
4
+ * gent ai status → show key source, model, where it came from
5
+ * gent ai test → make a tiny live request to confirm it works
6
+ * gent ai models → list the model ids gent suggests
7
+ */
8
+
9
+ const chalk = require('chalk');
10
+ const ora = require('ora');
11
+ const ai = require('../utils/ai-service');
12
+ const userConfig = require('../utils/user-config');
13
+
14
+ const SUGGESTED_MODELS = [
15
+ { id: 'claude-opus-4-7', tag: 'flagship', note: 'Highest quality' },
16
+ { id: 'claude-sonnet-4-6', tag: 'balanced', note: 'Strong, faster, cheaper' },
17
+ { id: 'claude-haiku-4-5', tag: 'fastest', note: 'Fastest & cheapest' },
18
+ ];
19
+
20
+ async function aiCommand(subcommand) {
21
+ const sub = (subcommand || 'status').toLowerCase();
22
+ switch (sub) {
23
+ case 'status': return status();
24
+ case 'test': return test();
25
+ case 'models': return models();
26
+ default:
27
+ console.error(chalk.red(`Unknown subcommand '${sub}'`));
28
+ console.log(chalk.gray('Usage: gent ai <status|test|models>'));
29
+ process.exit(1);
30
+ }
31
+ }
32
+
33
+ async function status() {
34
+ const { value: key, source: keySource } = await ai.resolveKey();
35
+ const model = await ai.resolveModel();
36
+ const { source: modelSource } = await userConfig.getResolved('ai.model');
37
+
38
+ console.log(chalk.bold.cyan('\nGent AI status\n'));
39
+ if (key) {
40
+ console.log(` ${chalk.green('●')} API key: ${userConfig.maskSecret(key)} ${chalk.gray(`[${keySource}]`)}`);
41
+ } else {
42
+ console.log(` ${chalk.gray('○')} API key: ${chalk.gray('not set')}`);
43
+ console.log(chalk.gray(' ↳ ' + ai.disabledHint()));
44
+ }
45
+ console.log(` ${chalk.green('●')} Model: ${model} ${chalk.gray(`[${modelSource}]`)}`);
46
+ console.log(chalk.gray('\n Run `gent ai test` to verify the key actually works.'));
47
+ console.log();
48
+ }
49
+
50
+ async function test() {
51
+ const { value: key } = await ai.resolveKey();
52
+ if (!key) {
53
+ console.error(chalk.red('No AI key configured.'));
54
+ console.log(chalk.yellow('Set one with `gent config set ai.api_key <key>`.'));
55
+ process.exit(1);
56
+ }
57
+
58
+ const model = await ai.resolveModel();
59
+ const spinner = ora(`Pinging Anthropic (${model})...`).start();
60
+ try {
61
+ const reply = await ai.complete({
62
+ prompt: 'Reply with the single word: pong',
63
+ maxTokens: 8,
64
+ });
65
+ spinner.succeed(chalk.green(`✓ Reachable. Reply: "${reply}"`));
66
+ } catch (err) {
67
+ spinner.fail(chalk.red(err.message));
68
+ process.exit(1);
69
+ }
70
+ }
71
+
72
+ async function models() {
73
+ const current = await ai.resolveModel();
74
+ console.log(chalk.bold.cyan('\nSuggested Claude models\n'));
75
+ for (const m of SUGGESTED_MODELS) {
76
+ const active = m.id === current ? chalk.green(' (current)') : '';
77
+ console.log(` ${chalk.cyan(m.id.padEnd(22))} ${chalk.gray(m.tag.padEnd(10))} ${m.note}${active}`);
78
+ }
79
+ console.log(chalk.gray('\n Switch with: gent config set ai.model <id>\n'));
80
+ }
81
+
82
+ module.exports = aiCommand;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Ask Command - Plain-English Q&A about the current repo.
3
+ *
4
+ * gent ask "what does this project do?"
5
+ * gent ask "who has touched src/server.js recently?"
6
+ * gent ask "what's pending on the current branch?"
7
+ *
8
+ * Builds a compact repo summary (README + last N commits + tree listing) and
9
+ * sends it as context. Falls back to a useful text dump if no AI key is set.
10
+ */
11
+
12
+ const path = require('path');
13
+ const chalk = require('chalk');
14
+ const ora = require('ora');
15
+ const { getGentPath, readJSON, pathExists } = require('../utils/fileSystem');
16
+ const fs = require('fs').promises;
17
+ const { COMMITS_FILE, CONFIG_FILE } = require('../utils/constants');
18
+ const ai = require('../utils/ai-service');
19
+
20
+ const MAX_CONTEXT_CHARS = 14000;
21
+ const MAX_COMMITS = 25;
22
+
23
+ async function ask(question, options = {}) {
24
+ try {
25
+ if (!question || !question.trim()) {
26
+ console.error(chalk.red('Usage: gent ask "<your question>"'));
27
+ process.exit(1);
28
+ }
29
+
30
+ const gentPath = await getGentPath();
31
+ const context = await buildRepoContext(gentPath);
32
+
33
+ if (!ai.isEnabled()) {
34
+ console.log(chalk.yellow(ai.disabledHint()));
35
+ console.log(chalk.gray('\nHere is the raw repo context you can pipe into another tool:\n'));
36
+ console.log(context);
37
+ return;
38
+ }
39
+
40
+ const spinner = ora(`Asking ${ai.getModel()}...`).start();
41
+ try {
42
+ const answer = await ai.complete({
43
+ system:
44
+ 'You are a senior engineer answering questions about a software repository. ' +
45
+ 'Be concrete and concise. If the answer is not in the context, say so. ' +
46
+ 'Reference filenames and short commit hashes when helpful.',
47
+ prompt: `Repository context:\n\n${context}\n\nQuestion: ${question}`,
48
+ maxTokens: 1024,
49
+ });
50
+ spinner.stop();
51
+ console.log('\n' + answer + '\n');
52
+ } catch (err) {
53
+ spinner.fail(chalk.red(err.message));
54
+ process.exit(1);
55
+ }
56
+ } catch (error) {
57
+ if (error.code === 'ENOENT' && error.message.includes('.gent')) {
58
+ console.error(chalk.red('Error: Not a gent repository'));
59
+ console.log(chalk.yellow('Run "gent init" to initialize a repository'));
60
+ } else {
61
+ console.error(chalk.red('Error:'), error.message);
62
+ }
63
+ process.exit(1);
64
+ }
65
+ }
66
+
67
+ async function buildRepoContext(gentPath) {
68
+ const cwd = process.cwd();
69
+ const parts = [];
70
+
71
+ // Project name + description from config
72
+ try {
73
+ const config = await readJSON(path.join(gentPath, CONFIG_FILE));
74
+ const repo = config.repository || {};
75
+ parts.push(`# Project\nname: ${repo.name || '(unnamed)'}\ndescription: ${repo.description || ''}`);
76
+ } catch { /* missing config — fine */ }
77
+
78
+ // README if present (any case, common extensions)
79
+ const readme = await findReadme(cwd);
80
+ if (readme) {
81
+ const text = await fs.readFile(readme.path, 'utf-8').catch(() => '');
82
+ if (text) parts.push(`# README (${readme.rel})\n${text.slice(0, 4000)}`);
83
+ }
84
+
85
+ // Recent commits
86
+ try {
87
+ const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
88
+ const commits = (repository.commits || []).slice(-MAX_COMMITS).reverse();
89
+ const branch = repository.currentBranch || 'main';
90
+ const lines = commits.map(c =>
91
+ `- ${(c.hash || '').slice(0, 7)} (${(c.author?.name || 'unknown')}): ${(c.message || '').split('\n')[0]}`
92
+ );
93
+ parts.push(`# Recent commits on '${branch}'\n${lines.join('\n')}`);
94
+ } catch { /* no commits yet — fine */ }
95
+
96
+ // Top-level layout
97
+ try {
98
+ const entries = await fs.readdir(cwd, { withFileTypes: true });
99
+ const layout = entries
100
+ .filter(e => !e.name.startsWith('.') && e.name !== 'node_modules')
101
+ .map(e => e.isDirectory() ? `${e.name}/` : e.name)
102
+ .slice(0, 60);
103
+ parts.push(`# Top-level layout\n${layout.join('\n')}`);
104
+ } catch { /* unreadable — fine */ }
105
+
106
+ const joined = parts.join('\n\n');
107
+ return joined.length > MAX_CONTEXT_CHARS
108
+ ? joined.slice(0, MAX_CONTEXT_CHARS) + '\n... (context truncated)'
109
+ : joined;
110
+ }
111
+
112
+ async function findReadme(cwd) {
113
+ const candidates = ['README.md', 'readme.md', 'README.txt', 'README', 'README.rst'];
114
+ for (const c of candidates) {
115
+ const p = path.join(cwd, c);
116
+ if (await pathExists(p)) return { path: p, rel: c };
117
+ }
118
+ return null;
119
+ }
120
+
121
+ module.exports = ask;
@@ -148,6 +148,8 @@ async function syncBranchCreate(name, commitSha, gentPath) {
148
148
  // Non-fatal: branch created locally even if remote sync fails
149
149
  if (error.response?.status === 400) {
150
150
  console.log(chalk.gray(` ⚠ Remote sync skipped (branch may already exist remotely)`));
151
+ } else if (error.response?.status === 403) {
152
+ console.log(chalk.yellow(` ⚠ Remote sync skipped — no write access to this repository`));
151
153
  }
152
154
  }
153
155
  }
@@ -173,6 +175,8 @@ async function syncBranchDelete(name, gentPath) {
173
175
  } catch (error) {
174
176
  if (error.response?.status === 400) {
175
177
  console.log(chalk.gray(` ⚠ Cannot delete default branch on remote`));
178
+ } else if (error.response?.status === 403) {
179
+ console.log(chalk.yellow(` ⚠ Remote delete skipped — no write access to this repository`));
176
180
  } else if (error.response?.status === 404) {
177
181
  // Branch didn't exist remotely, that's fine
178
182
  }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Changelog Command - AI-grouped changelog between two refs.
3
+ *
4
+ * gent changelog → since the most recent tag (or last 50 commits)
5
+ * gent changelog <from>..<to> → commits between two refs
6
+ * gent changelog <from> → from <from> to HEAD
7
+ * gent changelog --plain → flat list, no AI grouping
8
+ */
9
+
10
+ const path = require('path');
11
+ const chalk = require('chalk');
12
+ const ora = require('ora');
13
+ const { getGentPath, readJSON } = require('../utils/fileSystem');
14
+ const { COMMITS_FILE } = require('../utils/constants');
15
+ const ai = require('../utils/ai-service');
16
+
17
+ const MAX_COMMITS = 200;
18
+
19
+ async function changelog(range, options = {}) {
20
+ try {
21
+ const gentPath = await getGentPath();
22
+ const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
23
+ const commits = repository.commits || [];
24
+ if (commits.length === 0) {
25
+ console.log(chalk.yellow('No commits yet.'));
26
+ return;
27
+ }
28
+
29
+ const { from, to } = resolveRange(range, repository);
30
+ const selected = commitsBetween(commits, from, to);
31
+
32
+ if (selected.length === 0) {
33
+ console.log(chalk.yellow('No commits in the requested range.'));
34
+ return;
35
+ }
36
+
37
+ const header = `${chalk.bold.cyan('Changelog')} ${chalk.gray(
38
+ `${(from || 'root').slice(0, 7)}..${(to || 'HEAD').slice(0, 7)} ` +
39
+ `(${selected.length} commits)`
40
+ )}`;
41
+ console.log('\n' + header + '\n');
42
+
43
+ if (options.plain || !ai.isEnabled()) {
44
+ for (const c of selected) {
45
+ const short = (c.hash || '').slice(0, 7);
46
+ const subject = (c.message || '').split('\n')[0];
47
+ console.log(` ${chalk.gray(short)} ${subject}`);
48
+ }
49
+ if (!ai.isEnabled()) {
50
+ console.log(chalk.gray(`\n${ai.disabledHint()}`));
51
+ }
52
+ return;
53
+ }
54
+
55
+ const summary = selected.map(c =>
56
+ `- ${(c.hash || '').slice(0, 7)}: ${(c.message || '').split('\n')[0]}`
57
+ ).join('\n');
58
+
59
+ const spinner = ora(`Grouping with ${ai.getModel()}...`).start();
60
+ try {
61
+ const out = await ai.complete({
62
+ system:
63
+ 'You write release-note-style changelogs. Group commits into ' +
64
+ 'Features / Fixes / Improvements / Other. Keep each bullet to one line ' +
65
+ 'and start with a verb. Drop merge commits and noise. Reply with Markdown.',
66
+ prompt: `Commits (newest first):\n\n${summary}`,
67
+ maxTokens: 1500,
68
+ });
69
+ spinner.stop();
70
+ console.log(out + '\n');
71
+ } catch (err) {
72
+ spinner.fail(chalk.yellow('AI grouping failed — falling back to plain list'));
73
+ console.log(chalk.gray(`(${err.message})\n`));
74
+ for (const c of selected) {
75
+ console.log(` ${chalk.gray((c.hash || '').slice(0, 7))} ${(c.message || '').split('\n')[0]}`);
76
+ }
77
+ }
78
+ } catch (error) {
79
+ if (error.code === 'ENOENT' && error.message.includes('.gent')) {
80
+ console.error(chalk.red('Error: Not a gent repository'));
81
+ } else {
82
+ console.error(chalk.red('Error:'), error.message);
83
+ }
84
+ process.exit(1);
85
+ }
86
+ }
87
+
88
+ function resolveRange(range, repository) {
89
+ const head = repository.branches[repository.currentBranch] || null;
90
+ if (!range) {
91
+ // Use most recent tag if any, else null (means "all up to MAX_COMMITS")
92
+ const tags = repository.tags || {};
93
+ const tagHashes = Object.values(tags).map(t => t.hash || t.commit_sha).filter(Boolean);
94
+ return { from: tagHashes[tagHashes.length - 1] || null, to: head };
95
+ }
96
+ if (range.includes('..')) {
97
+ const [from, to] = range.split('..');
98
+ return { from: from || null, to: to || head };
99
+ }
100
+ return { from: range, to: head };
101
+ }
102
+
103
+ function commitsBetween(commits, from, to) {
104
+ const byHash = new Map(commits.map(c => [c.hash, c]));
105
+ const startHash = to || (commits[commits.length - 1] || {}).hash;
106
+ if (!startHash) return [];
107
+
108
+ const result = [];
109
+ let cur = startHash;
110
+ const seen = new Set();
111
+ while (cur && cur !== from && !seen.has(cur) && result.length < MAX_COMMITS) {
112
+ const c = byHash.get(cur);
113
+ if (!c) break;
114
+ seen.add(cur);
115
+ result.push(c);
116
+ cur = c.parent;
117
+ }
118
+ return result; // newest first
119
+ }
120
+
121
+ module.exports = changelog;
@@ -11,15 +11,12 @@
11
11
  * gent clone <url> → Clone into folder named after repo
12
12
  * gent clone <url> <directory> → Clone into specific directory
13
13
  *
14
- * ALGORITHM (client-side, no /clone/ endpoint):
14
+ * ALGORITHM:
15
15
  * 1. Parse URL to get owner_id + repo_name
16
- * 2. GET repo details name, description, default_branch
17
- * 3. GET branches list all branch names + SHAs
18
- * 4. GET commits list all commits
19
- * 5. For each commit, fetch tree + blobs
20
- * 6. Create .gent/ directory structure
21
- * 7. Store all objects locally
22
- * 8. Checkout HEAD (restore working tree from latest commit)
16
+ * 2. GET /clone/full snapshot (commits, base64 objects, branches, tags)
17
+ * 3. Create .gent/ structure + store objects locally
18
+ * 4. Write commits.json / config / HEAD / staging
19
+ * 5. Checkout the default branch's tree
23
20
  *
24
21
  * ============================================================================
25
22
  */
@@ -32,7 +29,7 @@ const { ensureDir, writeJSON, pathExists } = require('../utils/fileSystem');
32
29
  const { GENT_DIR, CONFIG_FILE, STAGING_FILE, COMMITS_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
33
30
  const apiClient = require('../utils/api-client');
34
31
  const authStorage = require('../utils/auth-storage');
35
- const { storeBlob, readBlobAsString, decodeRemoteBlobContent } = require('../utils/hash-engine');
32
+ const { storeBlob, readBlob } = require('../utils/hash-engine');
36
33
 
37
34
  /**
38
35
  * Clone remote repository
@@ -65,14 +62,14 @@ async function clone(url, directory, options) {
65
62
  return;
66
63
  }
67
64
 
68
- // 1. Get repo details
69
- spinner.text = 'Fetching repository info...';
70
- const repoDetail = await apiClient.get(
71
- buildRepoUrl(API_ENDPOINTS.REPO_DETAIL, repoInfo)
65
+ // Fetch the full repository snapshot in one call.
66
+ spinner.text = 'Fetching repository...';
67
+ const payload = await apiClient.get(
68
+ buildRepoUrl(API_ENDPOINTS.REPO_CLONE, repoInfo)
72
69
  );
73
70
 
74
- const repoName = repoDetail.name || repoInfo.repo_name;
75
- const defaultBranch = repoDetail.default_branch || 'main';
71
+ const repoName = payload.name || repoInfo.repo_name;
72
+ const defaultBranch = payload.currentBranch || 'main';
76
73
  const targetDir = directory || repoName;
77
74
  const targetPath = path.resolve(process.cwd(), targetDir);
78
75
 
@@ -84,39 +81,6 @@ async function clone(url, directory, options) {
84
81
  }
85
82
  }
86
83
 
87
- // 2. Get branches
88
- spinner.text = 'Fetching branches...';
89
- let remoteBranches = [];
90
- try {
91
- remoteBranches = await apiClient.get(
92
- buildRepoUrl(API_ENDPOINTS.REPO_BRANCHES, repoInfo)
93
- );
94
- } catch {
95
- // No branches yet
96
- }
97
-
98
- // 3. Get all commits
99
- spinner.text = 'Fetching commits...';
100
- let remoteCommits = [];
101
- try {
102
- remoteCommits = await apiClient.get(
103
- buildRepoUrl(API_ENDPOINTS.REPO_COMMITS, repoInfo)
104
- );
105
- } catch {
106
- // No commits yet
107
- }
108
-
109
- // 4. Get tags
110
- spinner.text = 'Fetching tags...';
111
- let remoteTags = [];
112
- try {
113
- remoteTags = await apiClient.get(
114
- buildRepoUrl(API_ENDPOINTS.REPO_TAGS, repoInfo)
115
- );
116
- } catch {
117
- // No tags
118
- }
119
-
120
84
  // Create directory structure
121
85
  spinner.text = 'Setting up repository...';
122
86
  const gentPath = path.join(targetPath, GENT_DIR);
@@ -125,100 +89,33 @@ async function clone(url, directory, options) {
125
89
  await ensureDir(path.join(gentPath, 'refs', 'heads'));
126
90
  await ensureDir(path.join(gentPath, 'refs', 'tags'));
127
91
 
128
- // 5. For each commit, fetch tree and blobs
129
- const localCommits = [];
92
+ // Store blob objects (base64) into the local object store.
93
+ spinner.text = 'Storing objects...';
130
94
  let objectCount = 0;
131
-
132
- for (let i = 0; i < remoteCommits.length; i++) {
133
- const commit = remoteCommits[i];
134
- spinner.text = `Fetching objects (${i + 1}/${remoteCommits.length})...`;
135
-
136
- let treeEntries = [];
137
- if (commit.tree_sha) {
138
- try {
139
- const tree = await apiClient.get(
140
- buildRepoUrl(API_ENDPOINTS.REPO_TREE_DETAIL, { ...repoInfo, sha: commit.tree_sha })
141
- );
142
- treeEntries = tree.entries || [];
143
- } catch {
144
- // Tree not available
145
- }
146
- }
147
-
148
- // Fetch and store blobs
149
- for (const entry of treeEntries) {
150
- if (entry.type === 'blob' && entry.sha) {
151
- try {
152
- const blob = await apiClient.get(
153
- buildRepoUrl(API_ENDPOINTS.REPO_BLOB_DETAIL, { ...repoInfo, sha: entry.sha })
154
- );
155
- if (blob.content) {
156
- const buf = decodeRemoteBlobContent(blob.content, entry.sha);
157
- await storeBlob(gentPath, buf);
158
- objectCount++;
159
- }
160
- } catch {
161
- // Blob fetch failed
162
- }
163
- }
164
- }
165
-
166
- // Convert to local commit format
167
- localCommits.push({
168
- hash: commit.sha,
169
- message: commit.message,
170
- author: { name: commit.author_name, email: commit.author_email },
171
- timestamp: commit.committed_at,
172
- parent: commit.parent_shas && commit.parent_shas[0] || null,
173
- mergeParent: commit.parent_shas && commit.parent_shas[1] || null,
174
- treeHash: commit.tree_sha,
175
- tree: treeEntries.map(e => ({
176
- mode: e.mode || '100644',
177
- name: e.name,
178
- hash: e.sha,
179
- type: e.type || 'blob'
180
- })),
181
- files: treeEntries.map(e => ({ path: e.name, hash: e.sha })),
182
- stats: {}
183
- });
95
+ for (const obj of payload.objects || []) {
96
+ if (obj.type !== 'blob' || typeof obj.data !== 'string') continue;
97
+ await storeBlob(gentPath, Buffer.from(obj.data, 'base64'));
98
+ objectCount++;
184
99
  }
185
100
 
186
- // Build branches map
187
- const branches = {};
188
- for (const b of remoteBranches) {
189
- branches[b.name] = b.commit_sha;
190
- }
191
- if (!branches[defaultBranch]) {
192
- branches[defaultBranch] = null;
193
- }
194
-
195
- // Build tags map
196
- const tagsMap = {};
197
- for (const t of remoteTags) {
198
- tagsMap[t.name] = {
199
- hash: t.commit_sha,
200
- message: t.message || '',
201
- annotated: t.annotated || false,
202
- tagger: { name: t.tagger_name || '', email: t.tagger_email || '' },
203
- timestamp: t.created_at
204
- };
205
- }
101
+ const localCommits = payload.commits || [];
102
+ const branches = payload.branches || {};
103
+ if (!(defaultBranch in branches)) branches[defaultBranch] = null;
206
104
 
207
105
  // Write commits.json
208
- const repoData = {
106
+ await writeJSON(path.join(gentPath, COMMITS_FILE), {
209
107
  commits: localCommits,
210
108
  branches,
211
109
  currentBranch: defaultBranch,
212
- tags: tagsMap
213
- };
214
- await writeJSON(path.join(gentPath, COMMITS_FILE), repoData);
110
+ tags: payload.tags || {}
111
+ });
215
112
 
216
113
  // Write config with remote
217
114
  const config = {
218
115
  user: { name: '', email: '' },
219
116
  repository: {
220
117
  name: repoName,
221
- description: repoDetail.description || '',
118
+ description: payload.description || '',
222
119
  created: new Date().toISOString()
223
120
  },
224
121
  remotes: {
@@ -256,11 +153,17 @@ async function clone(url, directory, options) {
256
153
  spinner.text = 'Checking out files...';
257
154
  let fileCount = 0;
258
155
  for (const entry of tree) {
156
+ if (entry.type && entry.type !== 'blob') continue;
157
+ const relPath = entry.name || entry.path;
158
+ if (!relPath || !entry.hash) continue;
259
159
  try {
260
- const content = await readBlobAsString(gentPath, entry.hash);
261
- const fullPath = path.join(targetPath, entry.name || entry.path);
160
+ // Write the raw Buffer — not a UTF-8 string. Decoding a
161
+ // binary blob (PNG, PDF, etc.) as UTF-8 would replace
162
+ // non-utf-8 bytes with U+FFFD, silently corrupting it.
163
+ const buf = await readBlob(gentPath, entry.hash);
164
+ const fullPath = path.join(targetPath, relPath);
262
165
  await fs.mkdir(path.dirname(fullPath), { recursive: true });
263
- await fs.writeFile(fullPath, content, 'utf-8');
166
+ await fs.writeFile(fullPath, buf);
264
167
  fileCount++;
265
168
  } catch {
266
169
  // Blob missing
@@ -282,6 +185,8 @@ async function clone(url, directory, options) {
282
185
  console.error(chalk.red('Repository not found'));
283
186
  } else if (error.response?.status === 401) {
284
187
  console.error(chalk.red('Authentication failed — run "gent login"'));
188
+ } else if (error.response?.status === 403) {
189
+ console.error(chalk.red('Access denied — you do not have permission to clone this repository'));
285
190
  } else if (error.response?.data) {
286
191
  console.error(chalk.red(JSON.stringify(error.response.data)));
287
192
  } else {