Skip to content

Commit 6b74fa5

Browse files
committed
refactor keys, add completion
1 parent eb4f290 commit 6b74fa5

10 files changed

Lines changed: 467 additions & 279 deletions

File tree

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,28 @@ Keys are stored securely using:
172172
- **Windows**: DPAPI encryption
173173
- **Linux**: File with restrictive permissions (`~/.iterable/keys.json`)
174174

175+
### Shell Completion
176+
177+
Enable tab completion for commands, subcommands, and flags:
178+
179+
```bash
180+
iterable completion install # auto-detects bash, zsh, or fish
181+
```
182+
183+
After restarting your shell (or sourcing your profile), tab completion is active:
184+
185+
```bash
186+
iterable <TAB> # shows categories
187+
iterable campaigns <TAB> # shows commands
188+
iterable campaigns list <TAB> # shows flags
189+
```
190+
191+
To remove:
192+
193+
```bash
194+
iterable completion uninstall
195+
```
196+
175197
## Development
176198

177199
```bash

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"chalk": "5.6.2",
6666
"cli-table3": "0.6.5",
6767
"inquirer": "13.3.2",
68+
"omelette": "0.4.17",
6869
"ora": "9.3.0",
6970
"zod": "4.3.6",
7071
"zod-opts": "1.0.0"
@@ -77,6 +78,7 @@
7778
"@types/inquirer": "9.0.9",
7879
"@types/jest": "30.0.0",
7980
"@types/node": "25.5.0",
81+
"@types/omelette": "0.4.5",
8082
"@typescript-eslint/eslint-plugin": "8.57.2",
8183
"@typescript-eslint/parser": "8.57.2",
8284
"eslint": "10.1.0",

pnpm-lock.yaml

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/completion.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import omelette from "omelette";
2+
3+
import {
4+
findCommand,
5+
getCategories,
6+
getCommandsByCategory,
7+
} from "./commands/registry.js";
8+
import { OUTPUT_FORMATS } from "./output.js";
9+
import { describeCommand } from "./parser.js";
10+
import { FLAG_DEFS } from "./router.js";
11+
import { COMMAND_NAME, KEYS_SUBCOMMANDS } from "./utils/command-info.js";
12+
13+
export const COMPLETION_SUBCOMMANDS = ["install", "uninstall"] as const;
14+
15+
const SPECIAL_CATEGORIES: Record<string, readonly string[]> = {
16+
keys: KEYS_SUBCOMMANDS,
17+
completion: COMPLETION_SUBCOMMANDS,
18+
};
19+
20+
const topLevelCompletions = [
21+
...getCategories(),
22+
...Object.keys(SPECIAL_CATEGORIES),
23+
];
24+
25+
const globalFlagCompletions = FLAG_DEFS.flatMap((d) => d.aliases);
26+
27+
const completion = omelette(COMMAND_NAME);
28+
29+
completion.on("complete", (_fragment, { before, reply, line }) => {
30+
const parts = line.trim().split(" ").slice(1);
31+
// Trailing space means the current word is complete — user wants next arg
32+
const depth = line.endsWith(" ") ? parts.length + 1 : parts.length;
33+
const [category, action] = parts;
34+
35+
if (depth <= 1) {
36+
reply([...topLevelCompletions, "--help", "--version"]);
37+
return;
38+
}
39+
40+
if (depth === 2) {
41+
const special = category ? SPECIAL_CATEGORIES[category] : undefined;
42+
if (special) {
43+
reply([...special]);
44+
return;
45+
}
46+
if (category) {
47+
reply([...getCommandsByCategory(category).map((c) => c.name), "--help"]);
48+
return;
49+
}
50+
}
51+
52+
if (category && action) {
53+
if (before === "--output") {
54+
reply([...OUTPUT_FORMATS]);
55+
return;
56+
}
57+
58+
if (before === "--key") {
59+
import("./key-manager.js")
60+
.then(({ getKeyManager }) => getKeyManager().listKeys())
61+
.then((keys) => reply(keys.map((k) => k.name)))
62+
.catch(() => reply([]));
63+
return;
64+
}
65+
66+
const cmd = findCommand(category, action);
67+
if (cmd) {
68+
const cmdFlags = describeCommand(cmd).map((f) => `--${f.name}`);
69+
reply([...cmdFlags, ...globalFlagCompletions]);
70+
return;
71+
}
72+
}
73+
74+
reply([]);
75+
});
76+
77+
export { completion };

src/index.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import chalk from "chalk";
44
import { readFileSync } from "fs";
55
import { z } from "zod";
66

7+
import { completion } from "./completion.js";
78
import { createClient, loadCliConfig } from "./config.js";
89
import { CliError, UsageError } from "./errors.js";
910
import { formatOutput, getDefaultFormat } from "./output.js";
@@ -18,6 +19,8 @@ import {
1819
} from "./router.js";
1920
import { COMMAND_NAME } from "./utils/command-info.js";
2021

22+
completion.init();
23+
2124
const CLI_PAGINATION_DEFAULTS: Record<string, number> = {
2225
page: 1,
2326
pageSize: 10,
@@ -40,6 +43,44 @@ async function main(): Promise<void> {
4043
return;
4144
}
4245

46+
if (parsed.category === "completion") {
47+
/* eslint-disable no-console */
48+
// omelette exposes getDefaultShellInitFile() but @types/omelette omits it
49+
let initFile: string | undefined;
50+
try {
51+
initFile = (
52+
completion as unknown as { getDefaultShellInitFile: () => string }
53+
).getDefaultShellInitFile();
54+
} catch {
55+
// Shell detection failed — we'll still try the operation
56+
}
57+
58+
let successMsg: string | undefined;
59+
60+
// Both setupShellInitFile and cleanupShellInitFile call process.exit(),
61+
process.on("exit", () => {
62+
if (!successMsg) return;
63+
console.log(successMsg);
64+
if (initFile) {
65+
console.log(` Modified: ${initFile}`);
66+
console.log(` Restart your shell or run: source ${initFile}`);
67+
}
68+
});
69+
70+
if (parsed.action === "install") {
71+
successMsg = "Shell completion installed.";
72+
completion.setupShellInitFile();
73+
} else if (parsed.action === "uninstall") {
74+
successMsg = "Shell completion removed.";
75+
completion.cleanupShellInitFile();
76+
} else {
77+
console.log(`Usage: ${COMMAND_NAME} completion install|uninstall`);
78+
}
79+
80+
/* eslint-enable no-console */
81+
return;
82+
}
83+
4384
if (parsed.category === "keys") {
4485
const { handleKeysCommand } = await import("./keys-cli.js");
4586
await handleKeysCommand(

src/key-manager.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,17 @@ enum StorageMethod {
7171
FILE = "file",
7272
}
7373

74-
export interface ApiKeyMetadata {
74+
/** User-facing key identity — the fields shown in display/help output. */
75+
export interface ApiKeySummary {
7576
/** Unique identifier for this key */
7677
id: string;
7778
/** User-friendly name for this key */
7879
name: string;
7980
/** Iterable API base URL (e.g., https://api.iterable.com or https://api.eu.iterable.com) */
8081
baseUrl: string;
82+
}
83+
84+
export interface ApiKeyMetadata extends ApiKeySummary {
8185
/** ISO timestamp when key was created */
8286
created: string;
8387
/** ISO timestamp when key was last updated */

0 commit comments

Comments
 (0)