mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-24 21:10:43 -07:00
feat(offload): implement unified control plane with log tailing and flexible attachment
This commit is contained in:
69
.gemini/skills/offload/scripts/attach.ts
Normal file
69
.gemini/skills/offload/scripts/attach.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Offload Attach Utility (Local)
|
||||
*
|
||||
* Re-attaches to a running tmux session on the worker.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
||||
|
||||
const q = (str: string) => `'${str.replace(/'/g, "'\\''")}'`;
|
||||
|
||||
export async function runAttach(args: string[], env: NodeJS.ProcessEnv = process.env) {
|
||||
const prNumber = args[0];
|
||||
const action = args[1] || 'review';
|
||||
const isLocal = args.includes('--local');
|
||||
|
||||
if (!prNumber) {
|
||||
console.error('Usage: npm run offload:attach <PR_NUMBER> [action] [--local]');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ... (load settings)
|
||||
const settingsPath = path.join(REPO_ROOT, '.gemini/settings.json');
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
const config = settings.maintainer?.deepReview;
|
||||
if (!config) {
|
||||
console.error('❌ Settings not found. Run "npm run offload:setup" first.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { remoteHost } = config;
|
||||
const sessionName = `offload-${prNumber}-${action}`;
|
||||
const finalSSH = `ssh -t ${remoteHost} "tmux attach-session -t ${sessionName}"`;
|
||||
|
||||
console.log(`🔗 Attaching to session: ${sessionName}...`);
|
||||
|
||||
// 2. Open in iTerm2 if within Gemini AND NOT --local
|
||||
const isWithinGemini = !!env.GEMINI_CLI || !!env.GEMINI_SESSION_ID || !!env.GCLI_SESSION_ID;
|
||||
if (isWithinGemini && !isLocal) {
|
||||
const tempCmdPath = path.join(process.env.TMPDIR || '/tmp', `offload-attach-${prNumber}.sh`);
|
||||
fs.writeFileSync(tempCmdPath, `#!/bin/bash\n${finalSSH}\nrm "$0"`, { mode: 0o755 });
|
||||
|
||||
const appleScript = `
|
||||
on run argv
|
||||
tell application "iTerm"
|
||||
set newWindow to (create window with default profile)
|
||||
tell current session of newWindow
|
||||
write text (item 1 of argv) & return
|
||||
end tell
|
||||
activate
|
||||
end tell
|
||||
end run
|
||||
`;
|
||||
spawnSync('osascript', ['-', tempCmdPath], { input: appleScript });
|
||||
console.log(`✅ iTerm2 window opened for ${sessionName}.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
spawnSync(finalSSH, { stdio: 'inherit', shell: true });
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runAttach(process.argv.slice(2)).catch(console.error);
|
||||
}
|
||||
@@ -108,6 +108,14 @@ async function stopWorker() {
|
||||
}
|
||||
}
|
||||
|
||||
async function remoteStatus() {
|
||||
const name = INSTANCE_PREFIX;
|
||||
const zone = 'us-west1-a';
|
||||
|
||||
console.log(`📡 Fetching remote status from ${name}...`);
|
||||
spawnSync('ssh', ['gcli-worker', 'tsx .offload/scripts/status.ts'], { stdio: 'inherit', shell: true });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const action = process.argv[2] || 'list';
|
||||
|
||||
@@ -121,6 +129,9 @@ async function main() {
|
||||
case 'stop':
|
||||
await stopWorker();
|
||||
break;
|
||||
case 'status':
|
||||
await remoteStatus();
|
||||
break;
|
||||
case 'create-image':
|
||||
await createImage();
|
||||
break;
|
||||
|
||||
50
.gemini/skills/offload/scripts/logs.ts
Normal file
50
.gemini/skills/offload/scripts/logs.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Offload Log Tailer (Local)
|
||||
*
|
||||
* Tails the latest remote logs for a specific job.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
||||
|
||||
export async function runLogs(args: string[]) {
|
||||
const prNumber = args[0];
|
||||
const action = args[1] || 'review';
|
||||
|
||||
if (!prNumber) {
|
||||
console.error('Usage: npm run offload:logs <PR_NUMBER> [action]');
|
||||
return 1;
|
||||
}
|
||||
|
||||
const settingsPath = path.join(REPO_ROOT, '.gemini/settings.json');
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
const config = settings.maintainer?.deepReview;
|
||||
const { remoteHost, remoteHome } = config;
|
||||
|
||||
const jobDir = `${remoteHome}/dev/worktrees/offload-${prNumber}-${action}`;
|
||||
const logDir = `${jobDir}/.gemini/logs`;
|
||||
|
||||
console.log(`📋 Tailing latest logs for job ${prNumber}-${action}...`);
|
||||
|
||||
// Remote command to find the latest log file and tail it
|
||||
const tailCmd = `
|
||||
latest_log=$(ls -t ${logDir}/*.log 2>/dev/null | head -n 1)
|
||||
if [ -z "$latest_log" ]; then
|
||||
echo "❌ No logs found for this job yet."
|
||||
exit 1
|
||||
fi
|
||||
echo "📄 Tailing: $latest_log"
|
||||
tail -f "$latest_log"
|
||||
`;
|
||||
|
||||
spawnSync(`ssh ${remoteHost} ${JSON.stringify(tailCmd)}`, { stdio: 'inherit', shell: true });
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runLogs(process.argv.slice(2)).catch(console.error);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export async function runOrchestrator(args: string[], env: NodeJS.ProcessEnv = p
|
||||
const setupCmd = `
|
||||
mkdir -p ${remoteHome}/dev/worktrees && \
|
||||
cd ${remoteWorkDir} && \
|
||||
git fetch origin pull/${prNumber}/head && \
|
||||
git fetch upstream pull/${prNumber}/head && \
|
||||
git worktree add -f ${remoteWorktreeDir} FETCH_HEAD
|
||||
`;
|
||||
|
||||
|
||||
@@ -2,24 +2,73 @@ import { TaskRunner } from '../TaskRunner.js';
|
||||
import path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
import { TaskRunner } from '../TaskRunner.js';
|
||||
import path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
|
||||
export async function runImplementPlaybook(issueNumber: string, workDir: string, policyPath: string, geminiBin: string) {
|
||||
const runner = new TaskRunner(
|
||||
path.join(workDir, `.gemini/logs/offload-issue-${issueNumber}`),
|
||||
`🚀 Offload | IMPLEMENT | Issue #${issueNumber}`
|
||||
);
|
||||
console.log(`🚀 Offload | IMPLEMENT (Supervisor Loop) | Issue #${issueNumber}`);
|
||||
|
||||
const ghView = spawnSync('gh', ['issue', 'view', issueNumber, '--json', 'title,body', '-q', '{title:.title,body:.body}'], { shell: true });
|
||||
const meta = JSON.parse(ghView.stdout.toString());
|
||||
const branchName = `impl/${issueNumber}-${meta.title.toLowerCase().replace(/[^a-z0-9]/g, '-')}`.slice(0, 50);
|
||||
|
||||
console.log(`🔍 Fetching metadata for Issue #${issueNumber}...`);
|
||||
const ghView = spawnSync('gh', ['issue', 'view', issueNumber, '--json', 'title', '-q', '.title'], { shell: true });
|
||||
const title = ghView.stdout.toString().trim() || `issue-${issueNumber}`;
|
||||
const branchName = `impl/${issueNumber}-${title.toLowerCase().replace(/[^a-z0-9]/g, '-')}`.slice(0, 50);
|
||||
// 1. Initial Research & Test Creation
|
||||
console.log('\n🧠 Phase 1: Research & Reproduction...');
|
||||
spawnSync(geminiBin, [
|
||||
'--policy', policyPath, '--cwd', workDir,
|
||||
'-p', `Research Issue #${issueNumber}: "${meta.title}".
|
||||
Description: ${meta.body}.
|
||||
ACTION: Create a NEW Vitest test file in 'tests/repro_issue_${issueNumber}.test.ts' that demonstrates the issue or feature.
|
||||
Ensure this test fails currently.`
|
||||
], { stdio: 'inherit' });
|
||||
|
||||
runner.register([
|
||||
{ id: 'branch', name: 'Create Branch', cmd: `git checkout -b ${branchName}` },
|
||||
{ id: 'research', name: 'Codebase Research', cmd: `${geminiBin} --policy ${policyPath} -p "Research the requirements for issue #${issueNumber} using 'gh issue view ${issueNumber}'. Map out the files that need to be changed."`, dep: 'branch' },
|
||||
{ id: 'implement', name: 'Implementation', cmd: `${geminiBin} --policy ${policyPath} -p "Implement the changes for issue #${issueNumber} based on your research. Ensure all code follows project standards."`, dep: 'research' },
|
||||
{ id: 'verify', name: 'Verification', cmd: `npm run build && npm test`, dep: 'implement' },
|
||||
{ id: 'pr', name: 'Create Pull Request', cmd: `git add . && git commit -m "feat: implement issue #${issueNumber}" && git push origin ${branchName} && gh pr create --title "${title}" --body "Closes #${issueNumber}"`, dep: 'verify' }
|
||||
]);
|
||||
// 2. The Self-Healing Loop
|
||||
let attempts = 0;
|
||||
const maxAttempts = 5;
|
||||
let success = false;
|
||||
|
||||
return runner.run();
|
||||
console.log('\n🛠️ Phase 2: Implementation Loop...');
|
||||
while (attempts < maxAttempts && !success) {
|
||||
attempts++;
|
||||
console.log(`\n👉 Attempt ${attempts}/${maxAttempts}...`);
|
||||
|
||||
// Run the specific repro test
|
||||
const testRun = spawnSync('npx', ['vitest', 'run', `tests/repro_issue_${issueNumber}.test.ts`], { cwd: workDir });
|
||||
|
||||
if (testRun.status === 0) {
|
||||
console.log('✅ Reproduction test PASSED!');
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
|
||||
console.log('❌ Test failed. Asking Gemini to fix the implementation...');
|
||||
const testError = testRun.stdout.toString() + testRun.stderr.toString();
|
||||
|
||||
spawnSync(geminiBin, [
|
||||
'--policy', policyPath, '--cwd', workDir,
|
||||
'-p', `The reproduction test for Issue #${issueNumber} is still failing.
|
||||
ERROR OUTPUT:
|
||||
${testError.slice(-2000)}
|
||||
|
||||
ACTION: Modify the source code to fix this error and make the test pass.
|
||||
Do not modify the test itself unless it has a syntax error.`
|
||||
], { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
// 3. Final Verification
|
||||
if (success) {
|
||||
console.log('\n🧪 Phase 3: Final Verification...');
|
||||
const finalCheck = spawnSync('npm', ['test'], { cwd: workDir, stdio: 'inherit' });
|
||||
if (finalCheck.status === 0) {
|
||||
console.log('\n🎉 Implementation complete and verified!');
|
||||
spawnSync('git', ['add', '.'], { cwd: workDir });
|
||||
spawnSync('git', ['commit', '-m', `feat: implement issue #${issueNumber}`], { cwd: workDir });
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
console.error('\n❌ Supervisor: Failed to reach a passing state within retry limit.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,34 @@ Host ${sshAlias}
|
||||
// 1. Configure Fast-Path SSH Alias
|
||||
// ... (unchanged)
|
||||
|
||||
// 1b. Security Fork Management
|
||||
console.log('\n🍴 Configuring Security Fork...');
|
||||
const upstreamRepo = 'google-gemini/gemini-cli';
|
||||
|
||||
const forkCheck = spawnSync('gh', ['repo', 'view', '--json', 'parent,nameWithOwner'], { stdio: 'pipe' });
|
||||
let currentRepo = '';
|
||||
try {
|
||||
const repoInfo = JSON.parse(forkCheck.stdout.toString());
|
||||
currentRepo = repoInfo.nameWithOwner;
|
||||
} catch (e) {}
|
||||
|
||||
let userFork = '';
|
||||
if (currentRepo.includes(`${env.USER}/`) || currentRepo.includes('mattkorwel/')) {
|
||||
userFork = currentRepo;
|
||||
console.log(` ✅ Using existing fork: ${userFork}`);
|
||||
} else {
|
||||
console.log(` 🔍 No personal fork detected for ${upstreamRepo}.`);
|
||||
if (await confirm(' Would you like to create a personal fork for autonomous work?')) {
|
||||
const forkResult = spawnSync('gh', ['repo', 'fork', upstreamRepo, '--clone=false'], { stdio: 'inherit' });
|
||||
if (forkResult.status === 0) {
|
||||
// Get the fork name (usually <user>/gemini-cli)
|
||||
const user = spawnSync('gh', ['api', 'user', '-q', '.login'], { stdio: 'pipe' }).stdout.toString().trim();
|
||||
userFork = `${user}/gemini-cli`;
|
||||
console.log(` ✅ Created fork: ${userFork}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use the alias for remaining setup steps
|
||||
const remoteHost = sshAlias;
|
||||
const remoteHome = spawnSync(`ssh ${remoteHost} "pwd"`, { shell: true }).stdout.toString().trim();
|
||||
@@ -109,25 +137,36 @@ Host ${sshAlias}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Remote GitHub Login
|
||||
if (await confirm('Authenticate GitHub CLI on the worker?')) {
|
||||
console.log('\n🔐 Performing non-interactive GitHub CLI authentication on worker...');
|
||||
const localToken = spawnSync('gh', ['auth', 'token'], { stdio: 'pipe' }).stdout.toString().trim();
|
||||
if (!localToken) {
|
||||
console.error('❌ Could not find local GitHub token. Please log in locally first with "gh auth login".');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Pipe the local token to the remote worker's gh auth login
|
||||
const loginCmd = `gh auth login --with-token --insecure-storage`;
|
||||
const result = spawnSync(`echo ${localToken} | ssh ${remoteHost} ${JSON.stringify(loginCmd)}`, { shell: true, stdio: 'inherit' });
|
||||
// 4. Scoped Token Onboarding (Security Hardening)
|
||||
if (await confirm('Generate a scoped, secure token for the autonomous agent? (Recommended)')) {
|
||||
const user = spawnSync('gh', ['api', 'user', '-q', '.login'], { stdio: 'pipe' }).stdout.toString().trim();
|
||||
|
||||
if (result.status === 0) {
|
||||
console.log('✅ GitHub CLI authenticated successfully on worker.');
|
||||
// Set git protocol to https by default for simplicity
|
||||
spawnSync(`ssh ${remoteHost} "gh config set git_protocol https"`, { shell: true });
|
||||
} else {
|
||||
console.error('❌ GitHub CLI authentication failed on worker.');
|
||||
// Construct the Pre-Filled Magic Link for Fine-Grained PAT
|
||||
const scopes = 'contents:write,pull_requests:write,metadata:read';
|
||||
const description = `Gemini CLI Offload - ${env.USER || 'maintainer'}`;
|
||||
const magicLink = `https://github.com/settings/tokens/beta/new?description=${encodeURIComponent(description)}&repositories[]=${encodeURIComponent(upstreamRepo)}&repositories[]=${encodeURIComponent(userFork)}&permissions[contents]=write&permissions[pull_requests]=write&permissions[metadata]=read`;
|
||||
|
||||
console.log('\n🔐 SECURITY HARDENING:');
|
||||
console.log('1. Open this Magic Link in your browser to create a scoped token:');
|
||||
console.log(` \x1b[34m${magicLink}\x1b[0m`);
|
||||
console.log('2. Click "Generate token" at the bottom of the page.');
|
||||
console.log('3. Copy the token and paste it here.');
|
||||
|
||||
const scopedToken = await prompt('\nPaste Scoped Token', '');
|
||||
|
||||
if (scopedToken) {
|
||||
console.log(` - Mirroring scoped token to worker...`);
|
||||
// Save it to a persistent file on the worker that entrypoint.ts will prioritize
|
||||
spawnSync(`ssh ${remoteHost} "mkdir -p ~/.offload && echo ${scopedToken} > ~/.offload/.gh_token && chmod 600 ~/.offload/.gh_token"`, { shell: true });
|
||||
console.log(' ✅ Scoped token saved on worker.');
|
||||
}
|
||||
} else {
|
||||
// Fallback: Standard gh auth login if they skip scoped token
|
||||
if (await confirm('Fallback: Authenticate via standard GitHub CLI login?')) {
|
||||
console.log('\n🔐 Starting GitHub CLI authentication on worker...');
|
||||
const localToken = spawnSync('gh', ['auth', 'token'], { stdio: 'pipe' }).stdout.toString().trim();
|
||||
const loginCmd = `gh auth login --with-token --insecure-storage`;
|
||||
spawnSync(`echo ${localToken} | ssh ${remoteHost} ${JSON.stringify(loginCmd)}`, { shell: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,9 +175,9 @@ Host ${sshAlias}
|
||||
console.log('🚀 Installing global developer tools...');
|
||||
spawnSync(`ssh ${remoteHost} "sudo npm install -g tsx vitest"`, { shell: true, stdio: 'inherit' });
|
||||
|
||||
console.log('🚀 Cloning repository (blob-less) on worker...');
|
||||
const repoUrl = 'https://github.com/google-gemini/gemini-cli.git';
|
||||
const cloneCmd = `[ -d ${remoteWorkDir}/.git ] || git clone --filter=blob:none ${repoUrl} ${remoteWorkDir}`;
|
||||
console.log(`🚀 Cloning fork ${userFork} on worker...`);
|
||||
const repoUrl = `https://github.com/${userFork}.git`;
|
||||
const cloneCmd = `[ -d ${remoteWorkDir}/.git ] || (git clone --filter=blob:none ${repoUrl} ${remoteWorkDir} && cd ${remoteWorkDir} && git remote add upstream https://github.com/${upstreamRepo}.git && git fetch upstream)`;
|
||||
spawnSync(`ssh ${remoteHost} ${JSON.stringify(cloneCmd)}`, { shell: true, stdio: 'inherit' });
|
||||
|
||||
// We skip the full npm install here as requested; per-worktree builds will handle it if needed.
|
||||
@@ -157,6 +196,8 @@ Host ${sshAlias}
|
||||
remoteHost,
|
||||
remoteHome,
|
||||
remoteWorkDir,
|
||||
userFork,
|
||||
upstreamRepo,
|
||||
terminalType: 'iterm2'
|
||||
};
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
|
||||
69
.gemini/skills/offload/scripts/status.ts
Normal file
69
.gemini/skills/offload/scripts/status.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Offload Status Inspector (Remote)
|
||||
*
|
||||
* Scans tmux sessions and logs to provide a real-time status of offload jobs.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const WORKTREE_BASE = path.join(os.homedir(), 'dev/worktrees');
|
||||
|
||||
function getStatus() {
|
||||
console.log('\n🛰️ Offload Mission Control Status:');
|
||||
console.log(''.padEnd(80, '-'));
|
||||
console.log(`${'JOB ID'.padEnd(10)} | ${'ACTION'.padEnd(10)} | ${'STATE'.padEnd(12)} | ${'SESSION'.padEnd(25)}`);
|
||||
console.log(''.padEnd(80, '-'));
|
||||
|
||||
// 1. Get active tmux sessions
|
||||
const tmux = spawnSync('tmux', ['ls', '-F', '#{session_name}']);
|
||||
const activeSessions = tmux.stdout.toString().split('\n').filter(s => s.startsWith('offload-'));
|
||||
|
||||
// 2. Scan worktrees for job history
|
||||
if (!fs.existsSync(WORKTREE_BASE)) {
|
||||
console.log(' No jobs found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const jobs = fs.readdirSync(WORKTREE_BASE).filter(d => d.startsWith('offload-'));
|
||||
|
||||
if (jobs.length === 0 && activeSessions.length === 0) {
|
||||
console.log(' No jobs found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const allJobIds = new Set([...jobs, ...activeSessions.map(s => s)]);
|
||||
|
||||
allJobIds.forEach(id => {
|
||||
const parts = id.split('-'); // offload-123-review
|
||||
const pr = parts[1] || '???';
|
||||
const action = parts[2] || '???';
|
||||
|
||||
let state = '💤 IDLE';
|
||||
if (activeSessions.includes(id)) {
|
||||
state = '🏃 RUNNING';
|
||||
} else {
|
||||
// Check logs for final state
|
||||
const logDir = path.join(WORKTREE_BASE, id, '.gemini/logs');
|
||||
if (fs.existsSync(logDir)) {
|
||||
const logFiles = fs.readdirSync(logDir).sort();
|
||||
if (logFiles.length > 0) {
|
||||
const lastLog = fs.readFileSync(path.join(logDir, logFiles[logFiles.length - 1]), 'utf8');
|
||||
if (lastLog.includes('SUCCESS')) state = '✅ SUCCESS';
|
||||
else if (lastLog.includes('FAILED')) state = '❌ FAILED';
|
||||
else state = '🏁 FINISHED';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${pr.padEnd(10)} | ${action.padEnd(10)} | ${state.padEnd(12)} | ${id.padEnd(25)}`);
|
||||
if (state === '🏃 RUNNING') {
|
||||
console.log(` └─ Attach: npm run offload:attach ${pr} ${action} [--local]`);
|
||||
console.log(` └─ Logs: npm run offload:logs ${pr} ${action}`);
|
||||
}
|
||||
});
|
||||
console.log(''.padEnd(80, '-'));
|
||||
}
|
||||
|
||||
getStatus();
|
||||
@@ -68,6 +68,9 @@
|
||||
"offload:check": "tsx .gemini/skills/offload/scripts/check.ts",
|
||||
"offload:clean": "tsx .gemini/skills/offload/scripts/clean.ts",
|
||||
"offload:fleet": "tsx .gemini/skills/offload/scripts/fleet.ts",
|
||||
"offload:status": "npm run offload:fleet status",
|
||||
"offload:attach": "tsx .gemini/skills/offload/scripts/attach.ts",
|
||||
"offload:logs": "tsx .gemini/skills/offload/scripts/logs.ts",
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
Reference in New Issue
Block a user