Merge branch 'main' into setup-scratch-agent-cleanup
This commit is contained in:
11
CLAUDE.md
11
CLAUDE.md
@@ -157,6 +157,17 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
|
|||||||
|
|
||||||
Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, `SKILL.md` format rules, and the pre-submission checklist.
|
Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, `SKILL.md` format rules, and the pre-submission checklist.
|
||||||
|
|
||||||
|
## PR Hygiene
|
||||||
|
|
||||||
|
Before creating a PR, run these checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff upstream/main --stat HEAD
|
||||||
|
git log upstream/main..HEAD --oneline
|
||||||
|
```
|
||||||
|
|
||||||
|
Show the output and wait for approval. Installation-specific files (group files, .claude/settings.json, local configs) should not be included.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Run commands directly — don't tell the user to run them.
|
Run commands directly — don't tell the user to run them.
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ Test your contribution on a fresh clone before submitting. For skills, run the s
|
|||||||
|
|
||||||
1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge.
|
1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge.
|
||||||
2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone.
|
2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone.
|
||||||
3. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
|
3. **Check for installation-specific files.** Before creating a PR, verify no installation-specific files are in your diff (see PR Hygiene in CLAUDE.md).
|
||||||
|
4. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
|
||||||
|
|
||||||
| Checkbox | Label |
|
| Checkbox | Label |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
|
|||||||
@@ -121,39 +121,6 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect existing .env and offer to reuse it so the user doesn't have to
|
|
||||||
// paste credentials again on a re-run.
|
|
||||||
const existingEnv = detectExistingEnv();
|
|
||||||
if (existingEnv) {
|
|
||||||
const lines = Object.values(existingEnv.groups).map(
|
|
||||||
(g) => ` ${k.green('✓')} ${g.label}`,
|
|
||||||
);
|
|
||||||
note(lines.join('\n'), 'Found existing configuration');
|
|
||||||
|
|
||||||
const reuseChoice = ensureAnswer(
|
|
||||||
await brightSelect({
|
|
||||||
message: 'Use this existing environment?',
|
|
||||||
options: [
|
|
||||||
{ value: 'reuse', label: 'Yes, use what I already have', hint: 'recommended' },
|
|
||||||
{ value: 'fresh', label: 'No, start fresh' },
|
|
||||||
],
|
|
||||||
initialValue: 'reuse',
|
|
||||||
}),
|
|
||||||
) as 'reuse' | 'fresh';
|
|
||||||
setupLog.userInput('existing_env_choice', reuseChoice);
|
|
||||||
|
|
||||||
if (reuseChoice === 'reuse') {
|
|
||||||
for (const [key, value] of Object.entries(existingEnv.raw)) {
|
|
||||||
if (!process.env[key]) process.env[key] = value;
|
|
||||||
}
|
|
||||||
if (existingEnv.groups.onecli) skip.add('onecli');
|
|
||||||
if (detectRegisteredGroups(process.cwd())) {
|
|
||||||
skip.add('cli-agent');
|
|
||||||
skip.add('first-chat');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!skip.has('container')) {
|
if (!skip.has('container')) {
|
||||||
p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)));
|
p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)));
|
||||||
p.log.message(
|
p.log.message(
|
||||||
@@ -343,6 +310,11 @@ async function main(): Promise<void> {
|
|||||||
return displayName;
|
return displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!skip.has('cli-agent') && detectRegisteredGroups(process.cwd())) {
|
||||||
|
skip.add('cli-agent');
|
||||||
|
skip.add('first-chat');
|
||||||
|
}
|
||||||
|
|
||||||
if (!skip.has('cli-agent')) {
|
if (!skip.has('cli-agent')) {
|
||||||
await resolveDisplayName();
|
await resolveDisplayName();
|
||||||
const res = await runQuietStep(
|
const res = await runQuietStep(
|
||||||
@@ -1079,56 +1051,6 @@ async function askChannelChoice(): Promise<ChannelChoice> {
|
|||||||
|
|
||||||
// ─── interactive / env helpers ─────────────────────────────────────────
|
// ─── interactive / env helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
interface ExistingEnvGroup {
|
|
||||||
label: string;
|
|
||||||
keys: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const ENV_KEY_GROUPS: Record<string, { label: string; keys: string[] }> = {
|
|
||||||
onecli: { label: 'OneCLI', keys: ['ONECLI_URL'] },
|
|
||||||
telegram: { label: 'Telegram', keys: ['TELEGRAM_BOT_TOKEN'] },
|
|
||||||
discord: { label: 'Discord', keys: ['DISCORD_BOT_TOKEN', 'DISCORD_APPLICATION_ID', 'DISCORD_PUBLIC_KEY'] },
|
|
||||||
slack: { label: 'Slack', keys: ['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET'] },
|
|
||||||
signal: { label: 'Signal', keys: ['SIGNAL_ACCOUNT'] },
|
|
||||||
teams: { label: 'Teams', keys: ['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_APP_TENANT_ID', 'TEAMS_APP_TYPE'] },
|
|
||||||
whatsapp: { label: 'WhatsApp', keys: ['ASSISTANT_HAS_OWN_NUMBER'] },
|
|
||||||
imessage: { label: 'iMessage', keys: ['IMESSAGE_LOCAL', 'IMESSAGE_ENABLED', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY'] },
|
|
||||||
};
|
|
||||||
|
|
||||||
function detectExistingEnv(): { groups: Record<string, ExistingEnvGroup>; raw: Record<string, string> } | null {
|
|
||||||
const envPath = path.join(process.cwd(), '.env');
|
|
||||||
if (!fs.existsSync(envPath)) return null;
|
|
||||||
|
|
||||||
let content: string;
|
|
||||||
try {
|
|
||||||
content = fs.readFileSync(envPath, 'utf-8');
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw: Record<string, string> = {};
|
|
||||||
for (const line of content.split('\n')) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
||||||
const eq = trimmed.indexOf('=');
|
|
||||||
if (eq < 1) continue;
|
|
||||||
raw[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(raw).length === 0) return null;
|
|
||||||
|
|
||||||
const groups: Record<string, ExistingEnvGroup> = {};
|
|
||||||
for (const [id, def] of Object.entries(ENV_KEY_GROUPS)) {
|
|
||||||
const found = def.keys.filter((key) => raw[key] !== undefined);
|
|
||||||
if (found.length > 0) {
|
|
||||||
groups[id] = { label: def.label, keys: found };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(groups).length === 0) return null;
|
|
||||||
return { groups, raw };
|
|
||||||
}
|
|
||||||
|
|
||||||
function anthropicSecretExists(): boolean {
|
function anthropicSecretExists(): boolean {
|
||||||
try {
|
try {
|
||||||
const res = spawnSync('onecli', ['secrets', 'list'], {
|
const res = spawnSync('onecli', ['secrets', 'list'], {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { brightSelect } from '../lib/bright-select.js';
|
|||||||
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
|
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
|
||||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||||
|
import { readEnvKey } from '../environment.js';
|
||||||
import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js';
|
import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js';
|
||||||
|
|
||||||
const DEFAULT_AGENT_NAME = 'Nano';
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
@@ -238,7 +239,7 @@ async function walkThroughServerCreation(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function collectDiscordToken(): Promise<string> {
|
async function collectDiscordToken(): Promise<string> {
|
||||||
const existing = process.env.DISCORD_BOT_TOKEN?.trim();
|
const existing = readEnvKey('DISCORD_BOT_TOKEN');
|
||||||
if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) {
|
if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) {
|
||||||
const reuse = ensureAnswer(await p.confirm({
|
const reuse = ensureAnswer(await p.confirm({
|
||||||
message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`,
|
message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { brightSelect } from '../lib/bright-select.js';
|
|||||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||||
import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
|
import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
|
||||||
|
import { readEnvKey } from '../environment.js';
|
||||||
|
|
||||||
const DEFAULT_AGENT_NAME = 'Nano';
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
|
|
||||||
@@ -222,8 +223,8 @@ async function walkThroughFullDiskAccess(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function collectRemoteCreds(): Promise<RemoteCreds> {
|
async function collectRemoteCreds(): Promise<RemoteCreds> {
|
||||||
const existingUrl = process.env.IMESSAGE_SERVER_URL?.trim();
|
const existingUrl = readEnvKey('IMESSAGE_SERVER_URL');
|
||||||
const existingKey = process.env.IMESSAGE_API_KEY?.trim();
|
const existingKey = readEnvKey('IMESSAGE_API_KEY');
|
||||||
if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) {
|
if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) {
|
||||||
const reuse = ensureAnswer(await p.confirm({
|
const reuse = ensureAnswer(await p.confirm({
|
||||||
message: `Found existing Photon credentials (${existingUrl}). Use them?`,
|
message: `Found existing Photon credentials (${existingUrl}). Use them?`,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import * as setupLog from '../logs.js';
|
|||||||
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
|
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
|
||||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||||
|
import { readEnvKey } from '../environment.js';
|
||||||
import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js';
|
import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js';
|
||||||
|
|
||||||
const SLACK_API = 'https://slack.com/api';
|
const SLACK_API = 'https://slack.com/api';
|
||||||
@@ -150,7 +151,7 @@ async function walkThroughAppCreation(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function collectBotToken(): Promise<string> {
|
async function collectBotToken(): Promise<string> {
|
||||||
const existing = process.env.SLACK_BOT_TOKEN?.trim();
|
const existing = readEnvKey('SLACK_BOT_TOKEN');
|
||||||
if (existing && existing.startsWith('xoxb-') && existing.length >= 24) {
|
if (existing && existing.startsWith('xoxb-') && existing.length >= 24) {
|
||||||
const reuse = ensureAnswer(await p.confirm({
|
const reuse = ensureAnswer(await p.confirm({
|
||||||
message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`,
|
message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`,
|
||||||
@@ -184,7 +185,7 @@ async function collectBotToken(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function collectSigningSecret(): Promise<string> {
|
async function collectSigningSecret(): Promise<string> {
|
||||||
const existing = process.env.SLACK_SIGNING_SECRET?.trim();
|
const existing = readEnvKey('SLACK_SIGNING_SECRET');
|
||||||
if (existing && /^[a-f0-9]{16,}$/i.test(existing)) {
|
if (existing && /^[a-f0-9]{16,}$/i.test(existing)) {
|
||||||
const reuse = ensureAnswer(await p.confirm({
|
const reuse = ensureAnswer(await p.confirm({
|
||||||
message: 'Found an existing Slack signing secret. Use it?',
|
message: 'Found an existing Slack signing secret. Use it?',
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
|||||||
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
|
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
|
||||||
import { note } from '../lib/theme.js';
|
import { note } from '../lib/theme.js';
|
||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
|
import { readEnvKey } from '../environment.js';
|
||||||
|
|
||||||
const CHANNEL = 'teams';
|
const CHANNEL = 'teams';
|
||||||
const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams');
|
const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams');
|
||||||
@@ -60,8 +61,8 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
|
|||||||
const collected: Collected = {};
|
const collected: Collected = {};
|
||||||
const completed: string[] = [];
|
const completed: string[] = [];
|
||||||
|
|
||||||
const existingAppId = process.env.TEAMS_APP_ID?.trim();
|
const existingAppId = readEnvKey('TEAMS_APP_ID');
|
||||||
const existingPassword = process.env.TEAMS_APP_PASSWORD?.trim();
|
const existingPassword = readEnvKey('TEAMS_APP_PASSWORD');
|
||||||
if (existingAppId && existingPassword) {
|
if (existingAppId && existingPassword) {
|
||||||
const reuse = ensureAnswer(await p.confirm({
|
const reuse = ensureAnswer(await p.confirm({
|
||||||
message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`,
|
message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`,
|
||||||
@@ -70,9 +71,9 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
|
|||||||
if (reuse) {
|
if (reuse) {
|
||||||
collected.appId = existingAppId;
|
collected.appId = existingAppId;
|
||||||
collected.appPassword = existingPassword;
|
collected.appPassword = existingPassword;
|
||||||
collected.appType = (process.env.TEAMS_APP_TYPE?.trim() as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
|
collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
|
||||||
if (collected.appType === 'SingleTenant') {
|
if (collected.appType === 'SingleTenant') {
|
||||||
collected.tenantId = process.env.TEAMS_APP_TENANT_ID?.trim();
|
collected.tenantId = readEnvKey('TEAMS_APP_TENANT_ID') ?? undefined;
|
||||||
}
|
}
|
||||||
setupLog.userInput('teams_credentials', 'reused-existing');
|
setupLog.userInput('teams_credentials', 'reused-existing');
|
||||||
await installAdapter(collected);
|
await installAdapter(collected);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
spawnStep,
|
spawnStep,
|
||||||
writeStepEntry,
|
writeStepEntry,
|
||||||
} from '../lib/runner.js';
|
} from '../lib/runner.js';
|
||||||
|
import { readEnvKey } from '../environment.js';
|
||||||
import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/theme.js';
|
import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/theme.js';
|
||||||
|
|
||||||
const DEFAULT_AGENT_NAME = 'Nano';
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
@@ -131,7 +132,7 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function collectTelegramToken(): Promise<string> {
|
async function collectTelegramToken(): Promise<string> {
|
||||||
const existing = process.env.TELEGRAM_BOT_TOKEN?.trim();
|
const existing = readEnvKey('TELEGRAM_BOT_TOKEN');
|
||||||
if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) {
|
if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) {
|
||||||
const reuse = ensureAnswer(await p.confirm({
|
const reuse = ensureAnswer(await p.confirm({
|
||||||
message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`,
|
message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`,
|
||||||
|
|||||||
@@ -11,6 +11,30 @@ import { log } from '../src/log.js';
|
|||||||
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
|
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
|
||||||
import { emitStatus } from './status.js';
|
import { emitStatus } from './status.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a single key from `.env` on disk (not process.env).
|
||||||
|
* Returns the trimmed value or null if the key isn't set / file doesn't exist.
|
||||||
|
*/
|
||||||
|
export function readEnvKey(key: string, projectRoot?: string): string | null {
|
||||||
|
const envPath = path.join(projectRoot ?? process.cwd(), '.env');
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = fs.readFileSync(envPath, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const eq = trimmed.indexOf('=');
|
||||||
|
if (eq < 1) continue;
|
||||||
|
if (trimmed.slice(0, eq) === key) {
|
||||||
|
return trimmed.slice(eq + 1).trim() || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function detectExistingDisplayName(projectRoot: string): string | null {
|
export function detectExistingDisplayName(projectRoot: string): string | null {
|
||||||
const dbPath = path.join(projectRoot, 'data', 'v2.db');
|
const dbPath = path.join(projectRoot, 'data', 'v2.db');
|
||||||
if (!fs.existsSync(dbPath)) return null;
|
if (!fs.existsSync(dbPath)) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user