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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,10 @@
"ora": "^9.3.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"sigstore": "^4.1.0",
"slugify": "^1.6.9",
"smol-toml": "^1.6.1",
"tar": "^7.5.9",
"ws": "^8.20.0",
"zod": "^3.25.76"
},
Expand Down
318 changes: 317 additions & 1 deletion pnpm-lock.yaml

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions scripts/postinstall-welcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ console.log(` Version: ${version}\n`);

// Display the welcome messages
console.log('Ably CLI installed successfully!');
console.log('To get started, explore commands:');
console.log(' ably --help');
console.log('\nOr log in to your Ably account:');
console.log(' ably login');
console.log('\nGet started in one command (authenticate + install Agent Skills):');
console.log(' ably init');
console.log('\nOr explore:');
console.log(' ably --help');
11 changes: 10 additions & 1 deletion src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ export const WEB_CLI_RESTRICTED_COMMANDS = [
// File-reading commands can expose server filesystem contents in web CLI mode
"push:config:set-apns",
"push:config:set-fcm",

// Agent-skills onboarding writes/removes files on the local filesystem —
// in web CLI mode these would touch the server's filesystem, not the user's.
"init",
"skills*",
];

/* Additional restricted commands when running in anonymous web CLI mode */
Expand Down Expand Up @@ -107,6 +112,7 @@ export const INTERACTIVE_UNSUITABLE_COMMANDS = [
"autocomplete", // Autocomplete setup is not needed in interactive mode
"config", // Config editing is not suitable for interactive mode
"version", // Version is shown at startup and available via --version
"init", // One-time setup; not meaningful inside an already-running session
];

// List of commands that should not show account/app info
Expand Down Expand Up @@ -877,9 +883,12 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
}

// Emit a terminal "completed" line so JSON consumers know the command is done.
// Suppressed when the command is being delegated to from another command
// (the outer command emits its own terminator).
const isJsonMode =
this.argv.includes("--json") || this.argv.includes("--pretty-json");
if (isJsonMode) {
const suppressCompleted = this.argv.includes("--skip-completed-status");
if (isJsonMode && !suppressCompleted) {
const flags: BaseFlags = this.argv.includes("--pretty-json")
? ({ "pretty-json": true } as BaseFlags)
: ({ json: true } as BaseFlags);
Expand Down
15 changes: 13 additions & 2 deletions src/commands/accounts/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,24 @@ export default class AccountsLogin extends ControlBaseCommand {
default: false,
description: "Do not open a browser",
}),
"skip-logo": Flags.boolean({
default: false,
hidden: true,
}),
"skip-completed-status": Flags.boolean({
default: false,
hidden: true,
description:
"Suppress the trailing JSON {status:'completed'} record. Used when this command is delegated to from another command (e.g. `init`) so the outer command's terminator is the only one in the stream.",
}),
};

public async run(): Promise<void> {
const { flags } = await this.parse(AccountsLogin);

// Display ASCII art logo if not in JSON mode
if (!this.shouldOutputJson(flags)) {
// Display ASCII art logo if not in JSON mode and the caller hasn't
// opted out (e.g. `ably init` already prints the logo and delegates here).
if (!this.shouldOutputJson(flags) && !flags["skip-logo"]) {
displayLogo(this.log.bind(this));
}

Expand Down
186 changes: 186 additions & 0 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { Flags } from "@oclif/core";
import chalk from "chalk";

import { AblyBaseCommand } from "../base-command.js";
import { coreGlobalFlags } from "../flags.js";
import {
runSkillsInstall,
SkillsInstallOutput,
} from "../services/skills-install-runner.js";
import { TARGET_CONFIGS } from "../services/skills-installer.js";
import { resolveSkillsTargets } from "../services/skills-target-prompt.js";
import { BaseFlags } from "../types/cli.js";
import { displayLogo } from "../utils/logo.js";
import { formatHeading, formatResource } from "../utils/output.js";
import isTestMode from "../utils/test-mode.js";

export default class Init extends AblyBaseCommand {
static override description =
"Set up Ably for AI-powered development — authenticate and install Agent Skills";

static override examples = [
"<%= config.bin %> <%= command.id %>",
"<%= config.bin %> <%= command.id %> --target claude-code",
"<%= config.bin %> <%= command.id %> --target cursor --target windsurf",
"<%= config.bin %> <%= command.id %> --target auto",
"<%= config.bin %> <%= command.id %> --json",
];

static override flags = {
...coreGlobalFlags,
target: Flags.string({
char: "t",
multiple: true,
options: ["auto", ...Object.keys(TARGET_CONFIGS)],
default: ["auto"],
description: "Target IDE(s) to install skills for",
}),
};

async run(): Promise<void> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When running this you get a double Ably logo, probably should do just one?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed - ensured it also still shows the Ably logo if someone uses login in isolation too

const { flags } = await this.parse(Init);
const jsonMode = this.shouldOutputJson(flags);

if (flags.target.includes("auto") && flags.target.length > 1) {
this.fail(
new Error(
"--target auto cannot be combined with explicit targets. Use either auto-detect or named targets, not both.",
),
flags,
"init",
);
}

if (!jsonMode) {
displayLogo(this.log.bind(this));
}

await this.runAuth(flags);

const resolvedTargets = await resolveSkillsTargets({
flags,
jsonMode,
log: this.log.bind(this),
warn: (msg) => this.logWarning(msg, flags),
exit: () => this.exit(130),
});
if (resolvedTargets === null) {
if (!jsonMode) this.displayGettingStarted();
return;
}

try {
await runSkillsInstall(
{ target: resolvedTargets },
this.buildInstallOutput(flags),
);
} catch (error) {
this.fail(error, flags, "init");
}

if (!jsonMode) {
this.displayGettingStarted();
}
}

private buildInstallOutput(flags: BaseFlags): SkillsInstallOutput {
return {
jsonMode: this.shouldOutputJson(flags),
progress: (msg) => this.logProgress(msg, flags),
success: (msg) => this.logSuccessMessage(msg, flags),
warning: (msg) => this.logWarning(msg, flags),
log: (msg) => this.log(msg),
emitResult: (data) => this.logJsonResult(data, flags),
};
}

private displayGettingStarted(): void {
const $ = chalk.green("$");
const cmd = (s: string) => chalk.cyan(s);
const note = (s: string) => chalk.dim(s);

this.log(`${formatHeading("Getting started with the Ably CLI")}\n`);
this.log(
"The Ably CLI lets you publish messages, subscribe to channels, manage",
);
this.log("apps and keys, and explore Ably from your terminal.\n");

this.log("Try it — open two terminals and run:");
this.log(
` ${$} ${cmd("ably channels subscribe my-channel")} ${note("# terminal 1")}`,
);
this.log(
` ${$} ${cmd('ably channels publish my-channel "hello world"')} ${note("# terminal 2")}\n`,
);

this.log("Useful next steps:");
this.log(
` ${$} ${cmd("ably --help")} ${note("# browse all commands")}\n`,
);

this.log("Docs: https://ably.com/docs/cli\n");
}

private async runAuth(flags: BaseFlags): Promise<void> {
if (this.hasControlApiAccess()) {
if (!this.shouldOutputJson(flags)) {
const account = this.configManager.getCurrentAccount();
const label = account?.accountName
? `${account.accountName}${account.accountId ? ` (${account.accountId})` : ""}`
: "stored credentials";
this.logSuccessMessage(
`Already authenticated with ${formatResource(label)}.`,
flags,
);
}
return;
}

if (!this.shouldOutputJson(flags)) {
this.log(`\n${formatHeading("Authenticate with Ably")}\n`);
}

// accounts:login handles JSON mode natively — emitting an
// awaiting_authorization event with userCode + verificationUri so
// headless callers can render the device-flow prompt themselves.
// We pass --skip-logo to avoid printing the Ably ASCII art twice
// (init already printed it above).
const loginArgv: string[] = ["--skip-logo"];
if (flags.json) loginArgv.push("--json");
else if (flags["pretty-json"]) loginArgv.push("--pretty-json");
// Suppress accounts:login's terminal {status:"completed"} JSON line so
// init's own terminator in finally() is the only one in the stream.
if (flags.json || flags["pretty-json"]) {
loginArgv.push("--skip-completed-status");
}

// Test hook: intercept the accounts:login delegation so unit tests can
// verify init's unauthenticated branch without spinning up the real
// OAuth device-code flow. Tests set globalThis.__TEST_MOCKS__.runLogin to
// a recording function or one that throws.
const loginRunner =
isTestMode() && globalThis.__TEST_MOCKS__?.runLogin
? (
globalThis.__TEST_MOCKS__ as {
runLogin: (argv: string[]) => Promise<void>;
}
).runLogin
: (argv: string[]) => this.config.runCommand("accounts:login", argv);

try {
await loginRunner(loginArgv);
} catch (error) {
this.fail(error, flags, "init");
}
}

// Checks for Control API auth (account-level OAuth access token), which is
// what `accounts:login` provides. Data-plane env vars (ABLY_API_KEY /
// ABLY_TOKEN) intentionally do NOT count here — they only authenticate the
// realtime/REST product API and don't grant Control API access (apps, keys,
// queues, integrations, etc.) that the rest of the CLI relies on.
private hasControlApiAccess(): boolean {
if (process.env.ABLY_ACCESS_TOKEN) return true;
return Boolean(this.configManager.getAccessToken());
}
}
13 changes: 13 additions & 0 deletions src/commands/skills/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { BaseTopicCommand } from "../../base-topic-command.js";

export default class Skills extends BaseTopicCommand {
protected topicName = "skills";
protected commandGroup = "Agent Skills";

static override description = "Install Ably Agent Skills for AI coding tools";

static override examples = [
"<%= config.bin %> <%= command.id %> install",
"<%= config.bin %> <%= command.id %> install --target claude-code",
];
}
79 changes: 79 additions & 0 deletions src/commands/skills/install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Flags } from "@oclif/core";

import { AblyBaseCommand } from "../../base-command.js";
import { coreGlobalFlags } from "../../flags.js";
import {
runSkillsInstall,
SkillsInstallOutput,
} from "../../services/skills-install-runner.js";
import { TARGET_CONFIGS } from "../../services/skills-installer.js";
import { resolveSkillsTargets } from "../../services/skills-target-prompt.js";
import { BaseFlags } from "../../types/cli.js";

export default class SkillsInstall extends AblyBaseCommand {
static override description =
"Install Ably Agent Skills into AI coding tools";

static override examples = [
"<%= config.bin %> <%= command.id %>",
"<%= config.bin %> <%= command.id %> --target claude-code",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Examples for other options?

"<%= config.bin %> <%= command.id %> --target cursor --target windsurf",
"<%= config.bin %> <%= command.id %> --target auto",
"<%= config.bin %> <%= command.id %> --json",
];

static override flags = {
...coreGlobalFlags,
target: Flags.string({
char: "t",
multiple: true,
options: ["auto", ...Object.keys(TARGET_CONFIGS)],
default: ["auto"],
description: "Target IDE(s) to install skills for",
}),
};

async run(): Promise<void> {
const { flags } = await this.parse(SkillsInstall);
const jsonMode = this.shouldOutputJson(flags);

if (flags.target.includes("auto") && flags.target.length > 1) {
this.fail(
new Error(
"--target auto cannot be combined with explicit targets. Use either auto-detect or named targets, not both.",
),
flags,
"skillsInstall",
);
}

const resolvedTargets = await resolveSkillsTargets({
flags,
jsonMode,
log: this.log.bind(this),
warn: (msg) => this.logWarning(msg, flags),
exit: () => this.exit(130),
});
if (resolvedTargets === null) return;

try {
await runSkillsInstall(
{ target: resolvedTargets },
this.buildInstallOutput(flags),
);
} catch (error) {
this.fail(error, flags, "skillsInstall");
}
}

protected buildInstallOutput(flags: BaseFlags): SkillsInstallOutput {
return {
jsonMode: this.shouldOutputJson(flags),
progress: (msg) => this.logProgress(msg, flags),
success: (msg) => this.logSuccessMessage(msg, flags),
warning: (msg) => this.logWarning(msg, flags),
log: (msg) => this.log(msg),
emitResult: (data) => this.logJsonResult(data, flags),
};
}
}
Loading
Loading