Skip to content

Commit 555c651

Browse files
committed
feat: interactive setup command, banner footer, and TTY-gated color
Add a 'setup [shell]' command that installs shell integration for the user: it detects the shell, then either appends a single 'shannon init' line to the startup file (resolving the live PowerShell $PROFILE) or prints just that line to paste — replacing the wall-of-snippet that 'init' dumped. 'init' stays the raw eval target. The banner gains a title/version/attribution footer, and output is colorized only when stdout is a TTY (honoring NO_COLOR/FORCE_COLOR) so pipes, eval'd snippets, and copied text stay plain. Standalone 'use' now points at 'setup'. Adds integration helpers + tests and documents it.
1 parent 08c2d5d commit 555c651

6 files changed

Lines changed: 370 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ Profile names must match `[A-Za-z0-9_-]+` (reject empty, leading `.`, `..`, `/`,
5353

5454
## Command interface
5555

56-
`create <name> [--from <src>] [--with-credentials]`, `list`/`ls`, `default [name]`, `which [name]`, `use <name>`, `clone <src> <dst> [--with-credentials]`, `delete`/`rm <name> [--yes]`, `status`/`st`, `init <shell>`, `update`/`upgrade`, `help`, `--version`. Bare `shannon` (no args) prints profile status and never launches `claude` implicitly. `shannon run …`, `shannon -- …`, or any non-subcommand token launches `claude` with the active profile. `create --from <src>` copies an existing profile (same shared copy routine as `clone`); both omit `.credentials.json` unless `--with-credentials` is passed.
56+
`create <name> [--from <src>] [--with-credentials]`, `list`/`ls`, `default [name]`, `which [name]`, `use <name>`, `clone <src> <dst> [--with-credentials]`, `delete`/`rm <name> [--yes]`, `status`/`st`, `setup [shell]` (interactive integration installer — adds one `init` line to the shell startup file, or shows it), `init <shell>` (raw emit, eval target), `update`/`upgrade`, `help`, `--version`. Bare `shannon` (no args) prints profile status and never launches `claude` implicitly. `shannon run …`, `shannon -- …`, or any non-subcommand token launches `claude` with the active profile. `create --from <src>` copies an existing profile (same shared copy routine as `clone`); both omit `.credentials.json` unless `--with-credentials` is passed.
5757

5858
## Design constraints (do not violate)
5959

@@ -72,6 +72,7 @@ Profile names must match `[A-Za-z0-9_-]+` (reject empty, leading `.`, `..`, `/`,
7272
- **M3 (done)**`create <name> --from <src> [--with-credentials]`: create a profile by copying an existing one, sharing the single `copyProfile` routine behind `clone` (credentials omitted unless `--with-credentials`).
7373
- **M4 (done)** — release CI: `.github/workflows/ci.yml` (build+test matrix) and `release.yml` (tag-triggered). On a `v*` tag it verifies the tag matches `package.json`, builds Node SEA single-file binaries for linux/macOS/Windows (esbuild bundles `build/sea-entry.ts` → CJS, `postject` injects the blob) uploaded to a GitHub Release via the `gh` CLI, then publishes to npm with provenance (`pnpm publish --provenance`, `id-token: write`, `NPM_TOKEN`). Every action is pinned by commit SHA; pnpm comes from corepack; build-only devDeps (`esbuild`, `postject`) are exact-pinned and runtime deps stay zero. See `RELEASING.md`.
7474
- **M5 (done)** — docs polish (no analytics): self-contained README with a quickstart, a complete command reference matching the CLI help, shell-integration / per-directory `.shannon` docs, an environment-variable note, a troubleshooting/FAQ, and a short `CONTRIBUTING.md` + `SECURITY.md`. No analytics, tracking, or SEO cruft.
75+
- **M7 (done)** — UX polish: banner gains a title/version/attribution footer; TTY-gated ANSI colour (`src/core/style.ts`, honoring `NO_COLOR`/`FORCE_COLOR`, never emitted to captured/eval'd output); and an interactive `setup [shell]` command (`src/core/integration.ts` for the pure bits) that adds a single `shannon init` line to the shell startup file for the user — or prints just that line to paste — instead of dumping the full snippet. `init` stays the raw eval target.
7576
- **M6 (done)** — branding + self-update: a low-entropy dot-halftone portrait banner (`src/banner.ts`) on `status`/`help` with terminal Unicode detection and an ASCII fallback (`SHANNON_BANNER` to force); a passive update check (`src/core/updates.ts`) that refreshes a ≤24h npm-registry cache in a detached background process and appends a one-line CTA to ordinary commands, plus an explicit `update`/`upgrade` command. Opt-out and zero-dep (built-in `fetch`); no runtime dependency added.
7677

7778
## When modifying

README.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ A bare `shannon` (or `claudep` / `clp`) prints profile status — it never launc
102102
| `clone <src> <dst> [--with-credentials]` | Copy a profile. Credentials are omitted unless `--with-credentials`. |
103103
| `delete <name> [--yes]` (`rm`) | Delete a profile and its data. `--yes` (aliases `-y`, `--force`) skips the confirmation prompt. |
104104
| `status` (`st`) | Show the active and default profile. |
105-
| `init <bash\|zsh\|fish\|pwsh>` | Print the shell integration snippet for that shell (see below). |
105+
| `setup [shell]` | Install shell integration interactively — adds one line to your startup file for you, or shows it to paste (see below). |
106+
| `init <bash\|zsh\|fish\|pwsh>` | Print the raw shell integration snippet for that shell (what `setup`'s one line re-generates at startup). |
106107
| `update` (`upgrade`) | Check the npm registry for a newer release and print how to upgrade. |
107108
| `help` (`-h`, `--help`) | Show the command reference. |
108109
| `--version` | Print the version. |
@@ -120,7 +121,15 @@ A bare `shannon` (or `claudep` / `clp`) prints profile status — it never launc
120121

121122
## Shell integration
122123

123-
The launcher already resolves the default profile, but two things need a shell function: making `use` switch the *live* shell, and auto-selecting a profile when you `cd` into a project. Add the line for your shell to its startup file:
124+
The launcher already resolves the default profile, but two things need a shell function: making `use` switch the *live* shell, and auto-selecting a profile when you `cd` into a project.
125+
126+
The easiest way is to let Shannon set it up for you:
127+
128+
```sh
129+
shannon setup # detects your shell, offers to add one line to its startup file
130+
```
131+
132+
`setup` is interactive: it either adds the line for you or prints just that line to paste — your call. To do it by hand, add the line for your shell to its startup file:
124133

125134
```sh
126135
# bash — ~/.bashrc
@@ -136,6 +145,8 @@ shannon init fish | source
136145
shannon init pwsh | Out-String | Invoke-Expression
137146
```
138147

148+
(`shannon init <shell>` prints the full integration that the one line above re-generates on each shell launch — you don't paste that yourself.)
149+
139150
This defines `shannon` / `claudep` / `clp` as thin wrappers around the binary. With it loaded:
140151

141152
```sh
@@ -158,6 +169,7 @@ Leaving the directory reverts to the default profile. A manual `shannon use` ove
158169
- **`CLAUDE_CONFIG_DIR`** — the variable Claude Code reads to find its config directory; setting it is how Shannon selects a profile. An explicit value in your environment **overrides the default profile** for that session, and that is what `status` and `list` report as *active*. `shannon use` (with shell integration) sets it for you.
159170
- **`SHANNON_AUTO`** — internal state managed by the shell hooks to track which profile was auto-selected from a `.shannon` file. You don't set this yourself.
160171
- **`SHANNON_BANNER`** — controls the portrait banner shown above `status` and `help` (only when stdout is a terminal — it's suppressed for pipes, scripts, and the shell-integration wrappers). By default Shannon auto-detects whether your terminal supports Unicode and falls back to an ASCII rendering otherwise; set `SHANNON_BANNER=ascii` or `SHANNON_BANNER=unicode` to force one.
172+
- **`NO_COLOR` / `FORCE_COLOR`** — Shannon colorizes output only when stdout is a terminal; set `NO_COLOR` to force plain output or `FORCE_COLOR` to keep colors when piping. (Colors are never written to captured/eval'd output, so copied text and shell snippets stay clean regardless.)
161173
- **`SHANNON_NO_UPDATE_CHECK`** — set to any value to disable the update check. Shannon otherwise checks the npm registry at most once a day (in a detached background process, never blocking a command) and appends a one-line notice when a newer release exists; the check sends no identifying data. `NO_UPDATE_NOTIFIER` and `CI` are also honored, and the notice is suppressed when output isn't a terminal.
162174

163175
## Profiles
@@ -175,7 +187,7 @@ Shannon launches the real `claude`; it doesn't bundle it. Install [Claude Code](
175187
Your package manager's global bin directory isn't on `PATH`. Find it with `pnpm bin -g` (or `npm bin -g`) and add it to your shell's `PATH`. With pnpm, `pnpm setup` configures this for you.
176188

177189
**`shannon use <name>` seems to do nothing.**
178-
A child process can't change its parent shell's environment, so on its own `use` only prints the export line for you to run. To make it switch the live shell, load [shell integration](#shell-integration) (`shannon init <shell>`) once. Until then, copy the printed `export …` / `$env:…` line.
190+
A child process can't change its parent shell's environment, so on its own `use` only prints the export line for you to run. To make it switch the live shell, run `shannon setup` once (see [shell integration](#shell-integration)). Until then, copy the printed `export …` / `$env:…` line.
179191

180192
**Migrating from `claude-code-profiles`.**
181193
Shannon's storage is byte-compatible with the original shell tool (same directory layout, same `.default` file). Your existing profiles just work — there's no migration step. Install Shannon, run `shannon list`, and they'll be there.

src/commands.ts

Lines changed: 194 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { spawnSync } from 'node:child_process';
2+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
3+
import { dirname } from 'node:path';
14
import { createInterface } from 'node:readline/promises';
25
import {
36
clearDefault,
@@ -23,6 +26,17 @@ import {
2326
refreshInBackground,
2427
updatesDisabled
2528
} from './core/updates.js';
29+
import {
30+
type Shell,
31+
SUPPORTED_SHELLS,
32+
alreadyInstalled,
33+
blockToAppend,
34+
detectShell,
35+
integrationLine,
36+
isSupportedShell,
37+
startupFile
38+
} from './core/integration.js';
39+
import { bold, cyan, dim, green, yellow } from './core/style.js';
2640

2741
// Single-sourced against package.json by test/version.test.ts, so a release
2842
// bump cannot ship a stale self-reported version on npm or the SEA binaries.
@@ -42,6 +56,7 @@ const MANAGEMENT = new Set<string>([
4256
'status',
4357
'st',
4458
'init',
59+
'setup',
4560
'update',
4661
'upgrade',
4762
'help',
@@ -121,6 +136,8 @@ async function route(argv: string[]): Promise<number> {
121136
return cmdStatus();
122137
case 'init':
123138
return cmdInit(argv.slice(1));
139+
case 'setup':
140+
return cmdSetup(argv.slice(1));
124141
case 'update':
125142
case 'upgrade':
126143
return cmdUpdate();
@@ -369,8 +386,12 @@ function cmdUse(args: string[]): number {
369386
}
370387

371388
// Standalone: a child process cannot mutate the parent shell, so show how.
389+
// The human-facing lines go to stderr; the copy/eval-able export lines go to
390+
// stdout (plain, so `eval`/redirect capture them cleanly).
391+
process.stderr.write(`Profile ${bold(name)}${dim(dir)}\n`);
372392
process.stderr.write(
373-
`Profile '${name}' resolved. For seamless 'use', run 'shannon init <shell>' (see help); until then activate it with:\n`
393+
`For automatic switching, run ${bold('shannon setup')} once. ` +
394+
`Or set it in this shell now:\n`
374395
);
375396
process.stdout.write(
376397
`${exportLine('posix', 'CLAUDE_CONFIG_DIR', dir)} # bash / zsh\n`
@@ -461,6 +482,169 @@ function cmdInit(args: string[]): number {
461482
return 0;
462483
}
463484

485+
/**
486+
* Interactive shell-integration installer. Rather than dumping the full snippet
487+
* (which belongs at shell-startup, not in the user's hands), it offers to add a
488+
* single `shannon init` line to the appropriate startup file for them, or to
489+
* print just that line to paste. Non-interactively it only prints instructions —
490+
* it never edits a file without a yes.
491+
*/
492+
async function cmdSetup(args: string[]): Promise<number> {
493+
const requested = args[0];
494+
let shell: Shell;
495+
if (requested !== undefined) {
496+
if (!isSupportedShell(requested)) {
497+
throw new ShannonError(
498+
`unsupported shell '${requested}'. Supported: ${SUPPORTED_SHELLS.join(', ')}`
499+
);
500+
}
501+
shell = requested;
502+
} else {
503+
shell = detectShell();
504+
}
505+
const line = integrationLine(shell);
506+
const file = resolveStartupFile(shell);
507+
508+
if (file && existsSync(file) && alreadyInstalled(readFileSync(file, 'utf8'))) {
509+
process.stdout.write(
510+
`${green('✓')} Shell integration already set up in ${bold(file)}.\n`
511+
);
512+
process.stdout.write(
513+
dim(` Restart your shell to pick up any changes.\n`)
514+
);
515+
return 0;
516+
}
517+
518+
// No prompting unless we have a real terminal both ways — otherwise just
519+
// show the manual steps so we never edit a file behind the user's back.
520+
if (!(process.stdin.isTTY && process.stdout.isTTY)) {
521+
printManualSetup(shell, line, file);
522+
return 0;
523+
}
524+
525+
process.stdout.write(
526+
`\nSet up ${bold(shell)} integration so ${bold('shannon use')} switches the ` +
527+
`live shell and a ${bold('.shannon')} file auto-selects a profile per directory.\n\n`
528+
);
529+
const choice = await promptMenu([
530+
file
531+
? `Set it up for me ${dim(`(adds one line to ${file})`)}`
532+
: 'Set it up for me',
533+
'Show me the line to add myself',
534+
'Cancel'
535+
]);
536+
537+
if (choice === 0) {
538+
if (!file) {
539+
process.stdout.write(
540+
yellow('\nCould not locate your PowerShell $PROFILE automatically.\n')
541+
);
542+
printManualSetup(shell, line, file);
543+
return 0;
544+
}
545+
mkdirSync(dirname(file), { recursive: true });
546+
appendFileSync(file, blockToAppend(shell));
547+
process.stdout.write(
548+
`\n${green('✓')} Added shannon integration to ${bold(file)}.\n`
549+
);
550+
process.stdout.write(
551+
dim(` Restart your shell (or run ${reloadHint(shell)}) to activate it.\n`)
552+
);
553+
return 0;
554+
}
555+
if (choice === 1) {
556+
printManualSetup(shell, line, file);
557+
return 0;
558+
}
559+
process.stdout.write('Cancelled.\n');
560+
return 0;
561+
}
562+
563+
/** Print the one line and where it goes, for manual copy/paste. */
564+
function printManualSetup(
565+
shell: Shell,
566+
line: string,
567+
file: string | null
568+
): void {
569+
const where = file ? bold(file) : 'your shell startup file';
570+
process.stdout.write(`\nAdd this line to ${where}:\n\n`);
571+
process.stdout.write(` ${cyan(line)}\n\n`);
572+
process.stdout.write(
573+
dim(`Then restart your shell or run ${reloadHint(shell)}.\n`)
574+
);
575+
}
576+
577+
/** The command to reload a shell's startup file in place. */
578+
function reloadHint(shell: Shell): string {
579+
switch (shell) {
580+
case 'pwsh':
581+
return '. $PROFILE';
582+
case 'fish':
583+
return 'source ~/.config/fish/config.fish';
584+
case 'zsh':
585+
return 'source ~/.zshrc';
586+
default:
587+
return 'source ~/.bashrc';
588+
}
589+
}
590+
591+
/**
592+
* Resolve the startup file to edit: the conventional path for POSIX shells, or
593+
* the live `$PROFILE` for PowerShell (queried by spawning pwsh/powershell, since
594+
* it varies — e.g. OneDrive-redirected Documents). Null if it can't be found.
595+
*/
596+
function resolveStartupFile(shell: Shell): string | null {
597+
if (shell !== 'pwsh') return startupFile(shell);
598+
for (const exe of ['pwsh', 'powershell']) {
599+
try {
600+
const r = spawnSync(
601+
exe,
602+
[
603+
'-NoProfile',
604+
'-NoLogo',
605+
'-Command',
606+
'$PROFILE.CurrentUserCurrentHost'
607+
],
608+
{ encoding: 'utf8' }
609+
);
610+
if (!r.error && r.status === 0) {
611+
const path = (r.stdout ?? '').trim();
612+
if (path) return path;
613+
}
614+
} catch {
615+
// Try the next executable.
616+
}
617+
}
618+
return null;
619+
}
620+
621+
/**
622+
* Print a numbered menu and return the chosen zero-based index. An empty answer
623+
* selects the first (recommended) option; out-of-range answers re-prompt.
624+
*/
625+
async function promptMenu(options: string[]): Promise<number> {
626+
options.forEach((opt, i) =>
627+
process.stdout.write(` ${bold(String(i + 1))} ${opt}\n`)
628+
);
629+
const rl = createInterface({
630+
input: process.stdin,
631+
output: process.stdout
632+
});
633+
try {
634+
for (;;) {
635+
const answer = (await rl.question(dim('\nChoose [1]: '))).trim();
636+
if (answer === '') return 0;
637+
const n = Number.parseInt(answer, 10);
638+
if (n >= 1 && n <= options.length) return n - 1;
639+
process.stdout.write(
640+
yellow(`Please enter a number between 1 and ${options.length}.\n`)
641+
);
642+
}
643+
} finally {
644+
rl.close();
645+
}
646+
}
647+
464648
/**
465649
* Internal: invoked by the `shannon init` directory hook. Emits the shell
466650
* commands needed to reconcile the auto-selected profile for the working
@@ -557,6 +741,13 @@ function printHelp(): number {
557741
function printBanner(): void {
558742
if (!process.stdout.isTTY) return;
559743
process.stdout.write(banner() + '\n');
744+
process.stdout.write(
745+
`${bold(cyan('shannon'))} ${dim(`v${VERSION}`)} ` +
746+
`${dim('— isolated Claude Code configuration profiles')}\n`
747+
);
748+
process.stdout.write(
749+
dim(' Not affiliated with Anthropic · a nod to Claude Shannon\n')
750+
);
560751
}
561752

562753
// --- helpers ---
@@ -746,7 +937,8 @@ Commands:
746937
clone <src> <dst> Copy a profile (omits credentials; --with-credentials to include)
747938
delete, rm <name> Delete a profile (--yes/-y/--force to skip confirmation)
748939
status, st Show active + default profile
749-
init <shell> Print shell integration (bash | zsh | fish | pwsh)
940+
setup [shell] Install shell integration (interactive; does it for you)
941+
init <shell> Print raw shell integration (bash | zsh | fish | pwsh)
750942
update, upgrade Check for a newer release and show how to upgrade
751943
help, -h, --help Show this help
752944
--version Show version

0 commit comments

Comments
 (0)