Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,12 +484,13 @@ Seeds for hotspot analysis are chosen by resolved preset (from `.charter/config.

Generates a live session snapshot and writes it to `.ai/context.adf` plus `.ai/context.snapshot.json`.

Phase 2 supports `git` and `github` sources with fail-closed behavior for missing GitHub credentials.
Supports `git`, `github`, and `repo-intel` sources with fail-closed behavior for missing GitHub credentials and graceful skip when the `gh` CLI is unavailable.

```bash
npx charter context-refresh
npx charter context-refresh --sources git
npx charter context-refresh --sources git,github
npx charter context-refresh --sources repo-intel
npx charter context-refresh --output CONTEXT.md
npx charter context-refresh --ai-dir .ai
npx charter context-refresh --once --ttl-minutes 30
Expand All @@ -498,7 +499,7 @@ npx charter context-refresh --format json

#### Flags

- `--sources <csv>` — context sources to include. Supported: `git`, `github`.
- `--sources <csv>` — context sources to include. Supported: `git`, `github`, `repo-intel`.
- `--output <path>` — optionally mirror a markdown snapshot to a file (for session briefs/docs).
- `--ai-dir <dir>` — target ADF directory (default: `.ai`), output file is `<dir>/context.adf`.
- `--once` — skip refresh when an existing snapshot is newer than TTL.
Expand Down Expand Up @@ -528,6 +529,16 @@ Optional config path: `.charter/context-sources.json`

If `github` is enabled but `GITHUB_TOKEN` is missing, refresh continues without hard failure and records `sources.github.available = false` plus warnings.

If `repo-intel` is enabled but the `gh` CLI is not installed or has no GitHub remote, refresh continues without hard failure and records a warning. When available, `repo-intel` writes a full payload to `.charter/repo-intel/snapshot.json`.

#### Sources reference

| Source | Description |
|--------|-------------|
| `git` | Local git branch, working tree, and recent commit log. |
| `github` | Open issues from the GitHub API (requires `GITHUB_TOKEN`). |
| `repo-intel` | GitHub history via the `gh` CLI — open/closed issues, PRs, releases, and a computed summary (`openIssueCount`, `mergeVelocity`, `stalledIssues`, `recurringLabels`, `releaseCadence`). Writes `.charter/repo-intel/snapshot.json`. Skips gracefully when `gh` is unavailable. |

For active implementation status and next-session handoff details, see [Context Refresh Resume Guide](/context-refresh-resume).

### charter surface
Expand Down
106 changes: 106 additions & 0 deletions packages/cli/src/__tests__/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import { driftCommand } from '../commands/drift';
import type { CLIOptions } from '../index';
import { parseAdf, parseManifest } from '@stackbilt/adf';

// Controlled per-test override for isGitRepo (git-helpers uses execFileSync, not execSync)
let mockIsGitRepo: boolean | null = null;
vi.mock('../git-helpers', async (importOriginal) => {
const actual = await importOriginal<typeof import('../git-helpers')>();
return { ...actual, isGitRepo: () => mockIsGitRepo !== null ? mockIsGitRepo : actual.isGitRepo() };
});

// Controlled per-test override for execSync (module-level mock needed for ESM-treated builtins)
let execSyncOverride: (((...args: unknown[]) => unknown) | null) = null;
vi.mock('node:child_process', async (importOriginal) => {
Expand Down Expand Up @@ -301,4 +308,103 @@ load state.adf always
const hasHint = installStep.warnings.some((w: string) => w.includes('--no-frozen-lockfile'));
expect(hasHint).toBe(true);
});

describe('--mode lean', () => {
it('skips migrate, install, and populate phases (status: skip)', async () => {
logs = [];
await bootstrapCommand(
{ ...baseOptions, format: 'json' },
['--preset', 'worker', '--mode', 'lean', '--skip-doctor'],
);

const report = JSON.parse(logs[0]);
const migrateStep = report.steps.find((s: { name: string }) => s.name === 'migrate');
const installStep = report.steps.find((s: { name: string }) => s.name === 'install');
const populateStep = report.steps.find((s: { name: string }) => s.name === 'populate');
expect(migrateStep.status).toBe('skip');
expect(installStep.status).toBe('skip');
expect(populateStep.status).toBe('skip');
});

it('exits with status success even when install would have failed', async () => {
execSyncOverride = () => {
throw new Error('ERR_PNPM_FROZEN_LOCKFILE: Lockfile is not up-to-date');
};

logs = [];
try {
await bootstrapCommand(
{ ...baseOptions, format: 'json' },
['--preset', 'worker', '--mode', 'lean', '--skip-doctor'],
);
} finally {
execSyncOverride = null;
}

const report = JSON.parse(logs[0]);
expect(report.status).toBe('success');
const installStep = report.steps.find((s: { name: string }) => s.name === 'install');
expect(installStep.status).toBe('skip');
});

it('emits the detected package manager install command as the first required next step', async () => {
fs.writeFileSync('pnpm-lock.yaml', '');
mockIsGitRepo = true;
logs = [];
await bootstrapCommand(
{ ...baseOptions, format: 'json' },
['--preset', 'worker', '--mode', 'lean', '--skip-doctor'],
);
mockIsGitRepo = null;

const report = JSON.parse(logs[0]);
expect(report.nextSteps.length).toBe(4);
const first = report.nextSteps[0];
expect(first.cmd).toBe('pnpm install');
expect(first.required).toBe(true);
});

it('emits exactly 4 deterministic next steps in the correct order (in a git repo)', async () => {
mockIsGitRepo = true;
logs = [];
await bootstrapCommand(
{ ...baseOptions, format: 'json' },
['--preset', 'worker', '--mode', 'lean', '--skip-doctor'],
);
mockIsGitRepo = null;

const report = JSON.parse(logs[0]);
expect(report.nextSteps.length).toBe(4);
expect(report.nextSteps[0].cmd).toMatch(/install$/);
expect(report.nextSteps[1].cmd).toBe('charter hook install --commit-msg');
expect(report.nextSteps[2].cmd).toBe('charter hook install --pre-commit');
expect(report.nextSteps[3].cmd).toBe('charter serve');
});

it('emits only install + serve next steps outside a git repo', async () => {
logs = [];
await bootstrapCommand(
{ ...baseOptions, format: 'json' },
['--preset', 'worker', '--mode', 'lean', '--skip-doctor'],
);

const report = JSON.parse(logs[0]);
expect(report.nextSteps.length).toBe(2);
expect(report.nextSteps[0].cmd).toMatch(/install$/);
expect(report.nextSteps[1].cmd).toBe('charter serve');
});

it('--mode lean combined with --skip-install is not an error', async () => {
logs = [];
const exitCode = await bootstrapCommand(
{ ...baseOptions, format: 'json' },
['--preset', 'worker', '--mode', 'lean', '--skip-install', '--skip-doctor'],
);

expect(exitCode).toBe(0);
const report = JSON.parse(logs[0]);
const installStep = report.steps.find((s: { name: string }) => s.name === 'install');
expect(installStep.status).toBe('skip');
});
});
});
Loading
Loading