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
24 changes: 24 additions & 0 deletions .changeset/cli-init-skip-unsupported-editors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@tigrisdata/cli': patch
---

Fix `tigris init` failing the whole skills step over one unsupported editor, and
the install banner appearing twice

Two things went wrong in a single `npx tigris@latest init` run:

- **Skills installed for nobody.** `init` passes every selected editor to the
upstream `skills` installer in one call, and that tool validates all of its
`-a` names before doing any work — so one name it doesn't know fails the step
for every editor. A user with Zed selected got
`Invalid agents: zed` and no skills at all, in any editor. Two changes: the
installer is now pinned to `skills@latest`, since npx otherwise reuses a
cached release that predates the newer agent names; and when the installer
does reject an editor, `init` warns about that editor and installs for the
rest instead of giving up.
- **The banner printed mid-wizard.** The package's postinstall banner is written
straight to `/dev/tty`, so it escaped the captured stdio of the `npm install
-g` that `init` and `tigris update` run and was painted over the wizard's own
prompts. Children the CLI spawns to install or update itself now set
`TIGRIS_NO_BANNER`, which postinstall honours — the banner still greets a
first-time install.
6 changes: 6 additions & 0 deletions packages/cli/postinstall.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ try {
}

// --- Show banner ---
// Skipped when the CLI is installing or updating itself (`tigris init` and
// `tigris update` set TIGRIS_NO_BANNER — see src/constants.ts). The banner goes
// straight to /dev/tty, so it escapes the parent's captured stdio and would be
// painted over the `init` wizard, and "To get started" is wrong for an update.
if (process.env.TIGRIS_NO_BANNER === '1') process.exit(0);

try {
const tty = openSync('/dev/tty', 'w');

Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ export const NPM_REGISTRY_URL =
export const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // Check for updates every 6 hours
export const UPDATE_NOTIFY_INTERVAL_MS = 1 * 60 * 60 * 1000; // Show update notification every 1 hour

// Set to '1' for children the CLI spawns to install or update itself, so the
// package's postinstall banner ("To get started: tigris login") stays out of
// their output. It writes straight to /dev/tty, so it escapes captured stdio and
// would land in the middle of the `init` wizard. Read by postinstall.cjs, which
// is plain CJS outside the TS build and so repeats the literal.
export const NO_BANNER_ENV = 'TIGRIS_NO_BANNER';

// Sentry DSN for CLI error telemetry, embedded at build time. A DSN is not a
// secret (it only permits sending events), so shipping it in the published CLI
// is expected. Overridable via TIGRIS_SENTRY_DSN. Empty keeps telemetry inert.
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/lib/init/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getOption } from '@utils/options.js';

import { NO_BANNER_ENV } from '../../constants.js';
import { runInteractive } from './interactive.js';
import { buildAgentSetup } from './plan.js';
import { getInstalledCliVersion, withoutEphemeralBins } from './shared.js';
Expand All @@ -19,6 +20,12 @@ export default async function init(options: Record<string, unknown>) {
// a TTY, would print mid-wizard or pollute the --agent recipe on stdout.
process.env.TIGRIS_NO_UPDATE_CHECK = '1';

// Likewise for the package's postinstall banner, which the CLI install and
// update below would each trigger. It is written straight to /dev/tty, so it
// escapes the captured stdio of those children and lands on top of the
// wizard's own prompts. Inherited by every child from here on.
process.env[NO_BANNER_ENV] = '1';

// Under `npx tigris init` this process *is* the CLI, reached through a bin
// directory npx drops from PATH as soon as it exits. Strip those entries for
// the whole command so no probe, update or handoff below can mistake that
Expand Down
99 changes: 83 additions & 16 deletions packages/cli/src/lib/init/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
SUPPORTED_EDITORS,
skillsDirsFor,
spawnOpts,
splitRejectedEditors,
TIGRIS_SKILLS,
upsertTomlServer,
} from './shared.js';
Expand Down Expand Up @@ -118,25 +119,11 @@ export async function runInteractive() {
}
}

// 7. Install the chosen Tigris agent skills. Output is captured (the skills
// tool prints a big banner); we report the destination dirs ourselves.
// 7. Install the chosen Tigris agent skills.
if (skillsLocation === 'skip' || skillIds.length === 0) {
p.log.info('Agent skills: skipped');
} else {
const agents = editors.map((e) => e.skillsAgent);
const args = buildSkillsArgs(skillIds, agents, skillsLocation === 'global');
const result = runCommand(
'npx',
args,
`Installing ${skillIds.length} Tigris skill(s) (${skillsLocation})`
);
if (result.ok) {
for (const dir of skillsDirsFor(editors, skillsLocation, cwd)) {
p.log.success(`Skills → ${prettyPath(dir, home)}`);
}
} else if (result.output) {
p.log.error(result.output.split('\n').slice(-6).join('\n'));
}
installSkills(editors, skillIds, skillsLocation, cwd, home);
}

// 8. Hand off to the agent — use the installed CLI, or npx if unavailable.
Expand Down Expand Up @@ -238,6 +225,79 @@ function writeMcp(
}
}

/**
* Install the chosen skills for the chosen editors, via the upstream `skills`
* installer. Output is captured (the tool prints a big banner of its own); we
* report the destination dirs ourselves.
*
* The installer takes every editor in one call and validates all of the `-a`
* names up front, so a single name it doesn't recognise — an older release that
* predates one of the editors we support — means nobody gets skills. When that
* happens, drop the editors it named and install for the rest: an editor the
* installer can't reach is worth a warning, not a failed step.
*
* `run` is the one seam the tests need: everything else here is a decision about
* what to run next and what to report, and only the spawn has to be faked.
*/
export function installSkills(
editors: EditorInfo[],
skillIds: string[],
scope: 'global' | 'project',
cwd: string,
home: string,
run: RunCommand = runCommand
): void {
const attempt = (targets: EditorInfo[], startMsg: string) =>
run(
'npx',
buildSkillsArgs(
skillIds,
[...new Set(targets.map((e) => e.skillsAgent))],
scope === 'global'
),
startMsg
);

let targets = editors;
let result = attempt(
targets,
`Installing ${skillIds.length} Tigris skill(s) (${scope})`
);

if (!result.ok) {
const { kept, dropped } = splitRejectedEditors(
targets,
result.output ?? ''
);
if (dropped.length > 0) {
p.log.warn(
`Skills: installer has no support for ${labels(dropped)} — skipped.`
);
if (kept.length === 0) {
p.log.info('Agent skills: skipped (no supported editor selected)');
return;
}
targets = kept;
result = attempt(
targets,
`Installing ${skillIds.length} Tigris skill(s) for ${labels(targets)}`
);
}
}
Comment thread
designcode marked this conversation as resolved.

if (result.ok) {
for (const dir of skillsDirsFor(targets, scope, cwd)) {
p.log.success(`Skills → ${prettyPath(dir, home)}`);
}
} else if (result.output) {
p.log.error(result.output.split('\n').slice(-6).join('\n'));
}
}

function labels(editors: EditorInfo[]): string {
return editors.map((e) => e.label).join(', ');
}

/** Keep only string-valued fields (TOML upsert writes `k = "v"`). */
function stringFields(entry: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
Expand Down Expand Up @@ -312,6 +372,13 @@ function installedCliCanHandOff(installed: string): boolean {
return true;
}

/** What `installSkills` needs of a command runner, so a test can stand in. */
export type RunCommand = (
cmd: string,
args: string[],
startMsg: string
) => { ok: boolean; output?: string };

/** Run a command under a spinner; capture output and surface it on failure. */
function runCommand(
cmd: string,
Expand Down
75 changes: 73 additions & 2 deletions packages/cli/src/lib/init/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -708,10 +708,18 @@ export const TIGRIS_SKILLS: SkillInfo[] = [
{ id: 'tigris-python-sdk', label: 'Python SDK', recommended: false },
];

/**
* The upstream installer, pinned to `@latest`. Given a bare `skills`, npx reuses
* whatever version is already in its cache without consulting the registry — and
* a stale one rejects agent names added since (`Invalid agents: zed`), which
* fails the install for every editor, not just the unknown one.
*/
const SKILLS_PACKAGE = 'skills@latest';

/**
* Args for the `npx` skills installer — non-interactive: installs the chosen
* skills to the given agents in one call. Run as `npx <args>`, e.g.
* `npx -y skills add github.com/tigrisdata/skills --skill tigris-sdk-guide -a claude-code`.
* `npx -y skills@latest add github.com/tigrisdata/skills --skill tigris-sdk-guide -a claude-code`.
* `global` adds `-g` (user directory) instead of the default project scope.
*/
export function buildSkillsArgs(
Expand All @@ -721,10 +729,73 @@ export function buildSkillsArgs(
): string[] {
// Leading `-y` is npx's auto-install; trailing `--yes` makes the skills tool
// itself non-interactive (it prompts by default).
const args = ['-y', 'skills', 'add', TIGRIS_SKILLS_REPO];
const args = ['-y', SKILLS_PACKAGE, 'add', TIGRIS_SKILLS_REPO];
for (const skill of skillIds) args.push('--skill', skill);
if (global) args.push('-g');
for (const agent of skillsAgents) args.push('-a', agent);
args.push('--yes');
return args;
}

/**
* Which of `agents` the skills installer refused, read off its failure output
* (`Invalid agents: zed`). It validates every `-a` name before doing any work,
* so one name it doesn't know — an installer predating an editor, or a name
* renamed upstream — installs nothing for anybody; init drops these and retries
* with the rest.
*
* Scoped to the `Invalid agents:` line, because the installer follows it with a
* `Valid agents:` list naming most of ours. Matched against the names we passed
* rather than by parsing the list, so an unrelated failure (no network, clone
* refused) drops nobody.
*/
export function rejectedAgents(output: string, agents: string[]): string[] {
const plain = output.replace(ANSI_ESCAPE, '');
const start = plain.search(/Invalid agents?:/i);
if (start === -1) return [];
const lineEnd = plain.indexOf('\n', start);
let line = plain.slice(start, lineEnd === -1 ? undefined : lineEnd);
// Should the valid list ever share the line, stop before it. `\b` keeps this
// off the `Invalid agents:` header itself — "nv" is not a word boundary.
const validList = line.search(/\bvalid agents?:/i);
if (validList !== -1) line = line.slice(0, validList);
// Word-ish boundaries so `antigravity` can't match `antigravity-cli`.
return agents.filter((agent) =>
new RegExp(`(?<![\\w-])${escapeRegExp(agent)}(?![\\w-])`).test(line)
);
}

/**
* Split the editors an install was requested for by whether the installer can
* reach them, given its failure output. `dropped` is what to warn about and
* leave out; `kept` is what to retry with. Both are empty-safe: output that
* doesn't name any of our agents keeps everything, so a failure with another
* cause (offline, clone refused) is reported as-is rather than read as an
* unsupported editor.
*/
export function splitRejectedEditors(
editors: EditorInfo[],
output: string
): { kept: EditorInfo[]; dropped: EditorInfo[] } {
const rejected = rejectedAgents(
output,
editors.map((e) => e.skillsAgent)
);
const isRejected = (e: EditorInfo) => rejected.includes(e.skillsAgent);
return {
kept: editors.filter((e) => !isRejected(e)),
dropped: editors.filter(isRejected),
};
}

function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/**
* SGR colour sequences, which the installer wraps its output in. They have to
* go before matching agent names: a sequence ends in a letter (`ESC[36m`), so
* `zed` in a coloured list would look like part of a longer word. Built from the
* escape's code point rather than written as a literal control character.
*/
const ANSI_ESCAPE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
5 changes: 4 additions & 1 deletion packages/cli/src/lib/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import {
getUpdateCommand,
isNewerVersion,
} from '@utils/update-check.js';

import { version as currentVersion } from '../../package.json';
import { NO_BANNER_ENV } from '../constants.js';

const context = msg('update');

Expand Down Expand Up @@ -50,6 +50,9 @@ export default async function update(
console.log('Updating...');
execSync(updateCommand, {
stdio: 'inherit',
// The npm path re-runs our postinstall; its banner greets a first-time
// install and only repeats what this command already reports.
env: { ...process.env, [NO_BANNER_ENV]: '1' },
...(process.platform === 'win32' ? { shell: 'powershell.exe' } : {}),
});
printSuccess(context, { latestVersion });
Expand Down
Loading