@switchbot/openapi-cli 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,65 @@
1
1
  import { getEffectiveCatalog } from '../devices/catalog.js';
2
2
  import { printJson } from '../utils/output.js';
3
+ import { enumArg, stringArg } from '../utils/arg-parsers.js';
4
+ const AGENT_GUIDE = {
5
+ safetyTiers: {
6
+ read: 'No state mutation; safe to call freely — does not consume quota unless noted.',
7
+ action: 'Mutates device or cloud state but is reversible and routine (turnOn, setColor).',
8
+ destructive: 'Hard to reverse / physical-world side effects (unlock, garage open, delete key). Requires explicit user confirmation.',
9
+ },
10
+ verifiability: {
11
+ local: 'Result is fully verifiable from the CLI return value itself.',
12
+ deviceConfirmed: 'Device returns an ack with an observable state field.',
13
+ deviceDependent: 'Verifiability depends on the specific device (IR is never verifiable).',
14
+ none: 'No feedback — e.g. IR transmission. Pair with an external sensor to confirm.',
15
+ },
16
+ };
17
+ const COMMAND_META = {
18
+ // devices: reads
19
+ 'devices list': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 600 },
20
+ 'devices status': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
21
+ 'devices describe': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 600 },
22
+ 'devices types': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
23
+ 'devices commands': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
24
+ 'devices watch': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
25
+ // devices: actions
26
+ 'devices command': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 800 },
27
+ 'devices batch': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1200 },
28
+ // scenes
29
+ 'scenes list': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
30
+ 'scenes execute': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1500 },
31
+ // webhook
32
+ 'webhook setup': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 500 },
33
+ 'webhook query': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
34
+ 'webhook delete': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'destructive', verifiability: 'local', typicalLatencyMs: 500 },
35
+ // quota
36
+ 'quota status': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
37
+ 'quota show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
38
+ 'quota reset': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 10 },
39
+ // doctor / schema / capabilities / catalog / config / cache / events / history / plan
40
+ 'doctor': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 900 },
41
+ 'schema export': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
42
+ 'capabilities': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 15 },
43
+ 'catalog': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 15 },
44
+ 'config set-token': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'destructive', verifiability: 'local', typicalLatencyMs: 5 },
45
+ 'config show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
46
+ 'config list-profiles': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
47
+ 'cache status': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
48
+ 'cache clear': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 5 },
49
+ 'events mqtt-tail': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
50
+ 'history show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
51
+ 'history replay': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1000 },
52
+ 'history range': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 50 },
53
+ 'history stats': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
54
+ 'plan run': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 2000 },
55
+ 'plan validate': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
56
+ 'plan schema': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
57
+ 'completion': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
58
+ 'mcp serve': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
59
+ };
60
+ function metaFor(command) {
61
+ return COMMAND_META[command] ?? null;
62
+ }
3
63
  const IDENTITY = {
4
64
  product: 'SwitchBot',
5
65
  domain: 'IoT smart home device control',
@@ -27,84 +87,162 @@ const MCP_TOOLS = [
27
87
  'search_catalog',
28
88
  'account_overview',
29
89
  'get_device_history',
90
+ 'query_device_history',
30
91
  ];
92
+ const IDEMPOTENCY_CONTRACT = {
93
+ flag: '--idempotency-key <key>',
94
+ windowSeconds: 60,
95
+ replayBehavior: 'Same (command, parameter, deviceId) within window → returns cached result with replayed:true.',
96
+ conflictBehavior: 'Same key + different (command, parameter) within window → exit 2, error:"idempotency_conflict".',
97
+ keyStorage: 'In-memory SHA-256 fingerprint; raw key never stored, no disk persistence.',
98
+ scope: 'Process-local. Replay + conflict apply within a single long-lived process (MCP session, devices batch, plan run, history replay). Independent CLI invocations do NOT share cache — each fresh `node` process starts empty.',
99
+ mcp: 'MCP send_command accepts the same idempotencyKey field with identical semantics.',
100
+ };
101
+ function enumerateLeaves(program, prefix = '') {
102
+ const out = [];
103
+ for (const cmd of program.commands) {
104
+ const full = prefix ? `${prefix} ${cmd.name()}` : cmd.name();
105
+ if (cmd.commands.length === 0) {
106
+ const meta = metaFor(full);
107
+ if (meta) {
108
+ out.push({ name: full, ...meta });
109
+ }
110
+ else {
111
+ // Unknown leaf → default to read-safe with a warning flag so agents notice.
112
+ out.push({
113
+ name: full,
114
+ mutating: false,
115
+ consumesQuota: false,
116
+ idempotencySupported: false,
117
+ agentSafetyTier: 'read',
118
+ verifiability: 'local',
119
+ typicalLatencyMs: 50,
120
+ });
121
+ }
122
+ }
123
+ else {
124
+ out.push(...enumerateLeaves(cmd, full));
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+ function projectObject(obj, fields) {
130
+ const out = {};
131
+ for (const f of fields) {
132
+ if (f in obj)
133
+ out[f] = obj[f];
134
+ }
135
+ return out;
136
+ }
31
137
  export function registerCapabilitiesCommand(program) {
138
+ const SURFACES = ['cli', 'mcp', 'plan', 'mqtt', 'all'];
32
139
  program
33
140
  .command('capabilities')
34
141
  .description('Print a machine-readable manifest of CLI capabilities (for agent bootstrap)')
35
- .option('--minimal', 'Omit per-subcommand flag details to reduce output size')
142
+ .option('--minimal', 'Omit per-subcommand flag details to reduce output size (alias of --compact)')
143
+ .option('--compact', 'Emit a compact summary: identity + leaf command list with safety metadata only')
144
+ .option('--surface <s>', 'Restrict surfaces block to one of: cli, mcp, plan, mqtt, all (default: all)', enumArg('--surface', SURFACES))
145
+ .option('--project <csv>', 'Project top-level fields (e.g. --project identity,commands,agentGuide)', stringArg('--project'))
36
146
  .action((opts) => {
147
+ const compact = Boolean(opts.minimal || opts.compact);
37
148
  const catalog = getEffectiveCatalog();
38
- const allCommands = [
39
- ...program.commands,
40
- // Commander adds 'help' implicitly; include it explicitly so it appears in the manifest
41
- { name: () => 'help', description: () => 'Display help for a command', commands: [], options: [], registeredArguments: [] },
42
- ];
43
- const commands = allCommands.map((c) => {
44
- const entry = {
45
- name: c.name(),
46
- description: c.description(),
47
- };
48
- if (!opts.minimal) {
49
- entry.subcommands = c.commands.map((s) => ({
50
- name: s.name(),
51
- description: s.description(),
52
- args: s.registeredArguments.map((a) => ({
53
- name: a.name(),
54
- required: a.required,
55
- variadic: a.variadic,
56
- })),
57
- flags: s.options.map((o) => ({
58
- flags: o.flags,
59
- description: o.description,
60
- })),
61
- }));
149
+ const leaves = enumerateLeaves(program);
150
+ const fullCommands = compact
151
+ ? undefined
152
+ : [
153
+ ...program.commands,
154
+ { name: () => 'help', description: () => 'Display help for a command', commands: [], options: [], registeredArguments: [] },
155
+ ].map((c) => {
156
+ const full = c.name();
157
+ const entry = {
158
+ name: full,
159
+ description: c.description(),
160
+ };
161
+ entry.subcommands = c.commands.map((s) => {
162
+ const leafName = `${full} ${s.name()}`;
163
+ const meta = metaFor(leafName);
164
+ return {
165
+ name: s.name(),
166
+ description: s.description(),
167
+ args: s.registeredArguments.map((a) => ({
168
+ name: a.name(),
169
+ required: a.required,
170
+ variadic: a.variadic,
171
+ })),
172
+ flags: s.options.map((o) => ({
173
+ flags: o.flags,
174
+ description: o.description,
175
+ })),
176
+ ...(meta ?? {}),
177
+ };
178
+ });
179
+ const selfMeta = metaFor(full);
180
+ if (selfMeta)
181
+ Object.assign(entry, selfMeta);
182
+ return entry;
183
+ });
184
+ const globalFlags = compact
185
+ ? undefined
186
+ : program.options.map((opt) => ({ flags: opt.flags, description: opt.description }));
187
+ const surfaces = {
188
+ mcp: {
189
+ entry: 'mcp serve',
190
+ protocol: 'stdio (default) or --port <n> for HTTP',
191
+ tools: MCP_TOOLS,
192
+ resources: ['switchbot://events'],
193
+ toolMeta: 'Each MCP tool mirrors the CLI leaf command metadata (mutating, consumesQuota, agentSafetyTier, idempotencySupported).',
194
+ },
195
+ mqtt: {
196
+ mode: 'consumer',
197
+ authSource: 'SWITCHBOT_TOKEN + SWITCHBOT_SECRET (auto-provisioned via POST /v1.1/iot/credential)',
198
+ cliCmd: 'events mqtt-tail',
199
+ mcpResource: 'switchbot://events',
200
+ protocol: 'MQTTS with TLS client certificates (AWS IoT)',
201
+ },
202
+ plan: {
203
+ schemaCmd: 'plan schema',
204
+ validateCmd: 'plan validate -',
205
+ runCmd: 'plan run -',
206
+ },
207
+ cli: {
208
+ catalogCmd: 'schema export',
209
+ discoveryCmd: 'capabilities',
210
+ healthCmd: 'doctor --json',
211
+ healthCmdSchemaVersion: 1,
212
+ helpFlag: '--help',
213
+ idempotencyContract: IDEMPOTENCY_CONTRACT,
214
+ },
215
+ };
216
+ const filteredSurfaces = (() => {
217
+ if (!opts.surface || opts.surface === 'all')
218
+ return surfaces;
219
+ const picked = {};
220
+ if (opts.surface in surfaces) {
221
+ picked[opts.surface] = surfaces[opts.surface];
62
222
  }
63
- return entry;
64
- });
65
- const globalFlags = program.options.map((opt) => ({
66
- flags: opt.flags,
67
- description: opt.description,
68
- }));
223
+ return picked;
224
+ })();
69
225
  const roles = [...new Set(catalog.map((e) => e.role ?? 'other'))].sort();
70
- printJson({
226
+ const payload = {
71
227
  version: program.version(),
72
- generatedAt: new Date().toISOString(),
228
+ schemaVersion: '2',
229
+ agentGuide: AGENT_GUIDE,
73
230
  identity: IDENTITY,
74
- surfaces: {
75
- mcp: {
76
- entry: 'mcp serve',
77
- protocol: 'stdio (default) or --port <n> for HTTP',
78
- tools: MCP_TOOLS,
79
- resources: ['switchbot://events'],
80
- },
81
- mqtt: {
82
- mode: 'consumer',
83
- authSource: 'SWITCHBOT_TOKEN + SWITCHBOT_SECRET (auto-provisioned via POST /v1.1/iot/credential)',
84
- cliCmd: 'events mqtt-tail',
85
- mcpResource: 'switchbot://events',
86
- protocol: 'MQTTS with TLS client certificates (AWS IoT)',
87
- },
88
- plan: {
89
- schemaCmd: 'plan schema',
90
- validateCmd: 'plan validate -',
91
- runCmd: 'plan run -',
92
- },
93
- cli: {
94
- catalogCmd: 'schema export',
95
- discoveryCmd: 'capabilities',
96
- healthCmd: 'doctor --json',
97
- helpFlag: '--help',
98
- },
99
- },
100
- commands,
101
- globalFlags,
231
+ surfaces: filteredSurfaces,
232
+ commands: compact ? leaves : fullCommands,
233
+ ...(globalFlags ? { globalFlags } : {}),
102
234
  catalog: {
103
235
  typeCount: catalog.length,
104
236
  roles,
105
237
  destructiveCommandCount: catalog.reduce((n, e) => n + e.commands.filter((c) => c.destructive).length, 0),
106
238
  readOnlyTypeCount: catalog.filter((e) => e.readOnly).length,
107
239
  },
108
- });
240
+ };
241
+ if (!compact)
242
+ payload.generatedAt = new Date().toISOString();
243
+ const projected = opts.project
244
+ ? projectObject(payload, opts.project.split(',').map((s) => s.trim()).filter(Boolean))
245
+ : payload;
246
+ printJson(projected);
109
247
  });
110
248
  }
@@ -1,7 +1,9 @@
1
1
  import fs from 'node:fs';
2
+ import readline from 'node:readline';
2
3
  import { execFileSync } from 'node:child_process';
3
4
  import { stringArg } from '../utils/arg-parsers.js';
4
- import { saveConfig, showConfig, listProfiles } from '../config.js';
5
+ import { intArg } from '../utils/arg-parsers.js';
6
+ import { saveConfig, showConfig, listProfiles, readProfileMeta } from '../config.js';
5
7
  import { isJsonMode, printJson } from '../utils/output.js';
6
8
  import chalk from 'chalk';
7
9
  function parseEnvFile(file) {
@@ -31,6 +33,62 @@ function readFromOp(ref) {
31
33
  const stdout = execFileSync('op', ['read', ref], { encoding: 'utf-8' });
32
34
  return stdout.trim();
33
35
  }
36
+ // Replace raw token/secret positional slots in process.argv with "***" so
37
+ // neither verbose traces nor crash dumps nor any later inspector observe them.
38
+ function scrubArgvCredentials() {
39
+ const argv = process.argv;
40
+ for (let i = 2; i < argv.length - 2; i++) {
41
+ if (argv[i] === 'config' && argv[i + 1] === 'set-token') {
42
+ // Slots i+2 and i+3 (if not option flags) are token/secret.
43
+ for (const off of [2, 3]) {
44
+ const slot = i + off;
45
+ if (slot < argv.length && !argv[slot].startsWith('-')) {
46
+ argv[slot] = '***';
47
+ }
48
+ }
49
+ return;
50
+ }
51
+ }
52
+ }
53
+ async function promptSecret(question) {
54
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true });
55
+ const stdoutAny = process.stdout;
56
+ const mutableStdout = process.stderr;
57
+ return new Promise((resolve) => {
58
+ process.stderr.write(question);
59
+ const stdin = process.stdin;
60
+ let answer = '';
61
+ const onData = (chunk) => {
62
+ const s = chunk.toString('utf-8');
63
+ for (const ch of s) {
64
+ if (ch === '\r' || ch === '\n') {
65
+ stdin.removeListener('data', onData);
66
+ if (stdin.setRawMode)
67
+ stdin.setRawMode(false);
68
+ stdin.pause();
69
+ process.stderr.write('\n');
70
+ rl.close();
71
+ resolve(answer);
72
+ return;
73
+ }
74
+ if (ch === '\u0003') {
75
+ process.exit(130);
76
+ }
77
+ if (ch === '\u007f' || ch === '\b') {
78
+ answer = answer.slice(0, -1);
79
+ continue;
80
+ }
81
+ answer += ch;
82
+ }
83
+ };
84
+ if (stdin.setRawMode)
85
+ stdin.setRawMode(true);
86
+ stdin.resume();
87
+ stdin.on('data', onData);
88
+ void stdoutAny;
89
+ void mutableStdout;
90
+ });
91
+ }
34
92
  export function registerConfigCommand(program) {
35
93
  const config = program
36
94
  .command('config')
@@ -53,18 +111,38 @@ Obtain your token/secret from the SwitchBot mobile app:
53
111
  .option('--from-env-file <path>', 'Read SWITCHBOT_TOKEN and SWITCHBOT_SECRET from a dotenv file', stringArg('--from-env-file'))
54
112
  .option('--from-op <tokenRef>', 'Read token via 1Password CLI (op read). Pair with --op-secret <ref>', stringArg('--from-op'))
55
113
  .option('--op-secret <secretRef>', '1Password reference for the secret, used with --from-op', stringArg('--op-secret'))
114
+ .option('--label <text>', 'Human-friendly label for this profile (shown in config show / list-profiles)', stringArg('--label'))
115
+ .option('--description <text>', 'Longer description, e.g. "home account" or "work devices"', stringArg('--description'))
116
+ .option('--daily-cap <n>', 'Local cap on SwitchBot API calls per UTC day for this profile', intArg('--daily-cap', { min: 1 }))
117
+ .option('--default-flags <csv>', 'Comma-separated flags auto-applied for this profile (e.g. "--audit-log")', stringArg('--default-flags'))
56
118
  .addHelpText('after', `
57
119
  Examples:
58
- $ switchbot config set-token <token> <secret>
59
- $ switchbot --profile work config set-token <token> <secret>
120
+ # Interactive (recommended) credentials never touch shell history / ps listing
121
+ $ switchbot config set-token
122
+ Token: ****
123
+ Secret: ****
124
+
125
+ # Import from dotenv / 1Password (non-interactive, still safe)
60
126
  $ switchbot config set-token --from-env-file ./.env
61
127
  $ switchbot config set-token --from-op op://vault/switchbot/token --op-secret op://vault/switchbot/secret
62
128
 
129
+ # Advanced / non-interactive (DISCOURAGED — leaks to shell history)
130
+ $ switchbot config set-token <token> <secret>
131
+ $ switchbot --profile work config set-token <token> <secret>
132
+
63
133
  Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<name>.json.
64
134
  `)
65
135
  .action(async (tokenArg, secretArg, options) => {
66
136
  let token = tokenArg;
67
137
  let secret = secretArg;
138
+ const hadPositional = tokenArg !== undefined && secretArg !== undefined;
139
+ // Scrub early: commander has already parsed the values, so we can safely
140
+ // rewrite argv before anything else (verbose trace, crash dumps, …) sees it.
141
+ if (hadPositional) {
142
+ scrubArgvCredentials();
143
+ console.error('⚠ Passing token/secret as positional arguments is discouraged — they may be persisted in shell history, process listings, and agent logs.');
144
+ console.error(' Prefer: switchbot config set-token (interactive), --from-env-file, or --from-op.');
145
+ }
68
146
  if (options.fromEnvFile) {
69
147
  if (!fs.existsSync(options.fromEnvFile)) {
70
148
  const msg = `--from-env-file: file not found: ${options.fromEnvFile}`;
@@ -107,8 +185,26 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
107
185
  process.exit(1);
108
186
  }
109
187
  }
188
+ // No credentials yet and stdin is a TTY → interactive prompt (safest path).
189
+ if ((!token || !secret) && !options.fromEnvFile && !options.fromOp && process.stdin.isTTY) {
190
+ if (isJsonMode()) {
191
+ const msg = 'Interactive mode cannot run under --json. Provide token/secret via --from-env-file, --from-op, or positional args.';
192
+ console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
193
+ process.exit(2);
194
+ }
195
+ try {
196
+ if (!token)
197
+ token = (await promptSecret('Token: ')).trim();
198
+ if (!secret)
199
+ secret = (await promptSecret('Secret: ')).trim();
200
+ }
201
+ catch {
202
+ console.error('Interactive prompt failed.');
203
+ process.exit(1);
204
+ }
205
+ }
110
206
  if (!token || !secret) {
111
- const msg = 'Missing token/secret. Provide positional arguments or use --from-env-file / --from-op.';
207
+ const msg = 'Missing token/secret. Run interactively, or use --from-env-file / --from-op, or pass positional arguments (discouraged).';
112
208
  if (isJsonMode()) {
113
209
  console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
114
210
  }
@@ -117,7 +213,19 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
117
213
  }
118
214
  process.exit(2);
119
215
  }
120
- saveConfig(token, secret);
216
+ saveConfig(token, secret, {
217
+ label: options.label,
218
+ description: options.description,
219
+ limits: options.dailyCap ? { dailyCap: Number.parseInt(options.dailyCap, 10) } : undefined,
220
+ defaults: options.defaultFlags
221
+ ? {
222
+ flags: options.defaultFlags
223
+ .split(',')
224
+ .map((s) => s.trim())
225
+ .filter(Boolean),
226
+ }
227
+ : undefined,
228
+ });
121
229
  if (isJsonMode()) {
122
230
  printJson({ ok: true, message: 'credentials saved' });
123
231
  }
@@ -133,18 +241,33 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
133
241
  });
134
242
  config
135
243
  .command('list-profiles')
136
- .description('List named profiles under ~/.switchbot/profiles/')
244
+ .description('List named profiles under ~/.switchbot/profiles/ (with labels and daily caps)')
137
245
  .action(() => {
138
246
  const profiles = listProfiles();
247
+ const enriched = profiles.map((p) => {
248
+ const meta = readProfileMeta(p);
249
+ return {
250
+ name: p,
251
+ label: meta?.label,
252
+ description: meta?.description,
253
+ dailyCap: meta?.limits?.dailyCap,
254
+ };
255
+ });
139
256
  if (isJsonMode()) {
140
- printJson({ profiles });
257
+ printJson({ profiles: enriched });
141
258
  return;
142
259
  }
143
260
  if (profiles.length === 0) {
144
261
  console.log('No profiles. Create one with: switchbot --profile <name> config set-token ...');
145
262
  return;
146
263
  }
147
- for (const p of profiles)
148
- console.log(p);
264
+ for (const p of enriched) {
265
+ const bits = [p.name];
266
+ if (p.label)
267
+ bits.push(`— ${p.label}`);
268
+ if (p.dailyCap)
269
+ bits.push(`[dailyCap=${p.dailyCap}]`);
270
+ console.log(bits.join(' '));
271
+ }
149
272
  });
150
273
  }
@@ -200,6 +200,10 @@ Examples:
200
200
  .description('Query the real-time status of a specific device')
201
201
  .argument('[deviceId]', 'Device ID from "devices list" (or use --name or --ids)')
202
202
  .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
203
+ .option('--name-strategy <s>', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default: fuzzy)', stringArg('--name-strategy'))
204
+ .option('--name-type <type>', 'Narrow --name by device type (e.g. "Bot", "Color Bulb")', stringArg('--name-type'))
205
+ .option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
206
+ .option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
203
207
  .option('--ids <list>', 'Comma-separated device IDs for batch status (incompatible with --name)', stringArg('--ids'))
204
208
  .addHelpText('after', `
205
209
  Status fields vary by device type. To discover them without a live call:
@@ -261,7 +265,12 @@ Examples:
261
265
  }
262
266
  return;
263
267
  }
264
- const deviceId = resolveDeviceId(deviceIdArg, options.name);
268
+ const deviceId = resolveDeviceId(deviceIdArg, options.name, {
269
+ strategy: options.nameStrategy ?? 'fuzzy',
270
+ type: options.nameType,
271
+ category: options.nameCategory,
272
+ room: options.nameRoom,
273
+ });
265
274
  const body = await fetchDeviceStatus(deviceId);
266
275
  const fetchedAt = new Date().toISOString();
267
276
  const fmt = resolveFormat();
@@ -292,6 +301,10 @@ Examples:
292
301
  .argument('[cmd]', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean')
293
302
  .argument('[parameter]', 'Command parameter. Omit for commands like turnOn/turnOff (defaults to "default"). Format depends on the command (see below).')
294
303
  .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
304
+ .option('--name-strategy <s>', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default for command: require-unique)', stringArg('--name-strategy'))
305
+ .option('--name-type <type>', 'Narrow --name by device type (e.g. "Bot", "Color Bulb")', stringArg('--name-type'))
306
+ .option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
307
+ .option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
295
308
  .option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
296
309
  .option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.')
297
310
  .option('--idempotency-key <key>', 'Idempotency key for deduplication (60s window; same key replays cached result)', stringArg('--idempotency-key'))
@@ -367,7 +380,13 @@ Examples:
367
380
  cmd = cmdArg;
368
381
  effectiveDeviceIdArg = deviceIdArg;
369
382
  }
370
- const deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name);
383
+ const deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name, {
384
+ // Mutating command → default require-unique (never silently pick between ambiguous matches).
385
+ strategy: options.nameStrategy ?? 'require-unique',
386
+ type: options.nameType,
387
+ category: options.nameCategory,
388
+ room: options.nameRoom,
389
+ });
371
390
  if (!getCachedDevice(deviceId)) {
372
391
  console.error(`Note: device ${deviceId} is not in the local cache — run 'switchbot devices list' first to enable command validation.`);
373
392
  }
@@ -469,10 +488,19 @@ Examples:
469
488
  }
470
489
  const body = await executeCommand(deviceId, cmd, parsedParam, options.type, undefined, { idempotencyKey: options.idempotencyKey });
471
490
  const isIr = getCachedDevice(deviceId)?.category === 'ir';
491
+ const verification = isIr
492
+ ? {
493
+ verifiable: false,
494
+ reason: 'IR transmission is unidirectional; no receipt acknowledgment is possible.',
495
+ suggestedFollowup: 'Confirm visible change manually or via a paired state sensor.',
496
+ }
497
+ : null;
472
498
  if (isJsonMode()) {
473
499
  const result = { ok: true, command: cmd, deviceId };
474
- if (isIr)
500
+ if (isIr) {
475
501
  result.subKind = 'ir-no-feedback';
502
+ result.verification = verification;
503
+ }
476
504
  if (body && typeof body === 'object' && Object.keys(body).length > 0) {
477
505
  Object.assign(result, body);
478
506
  }
@@ -481,6 +509,7 @@ Examples:
481
509
  }
482
510
  if (isIr) {
483
511
  console.log(`→ IR signal sent: ${cmd} (no feedback — fire-and-forget)`);
512
+ console.error('⚠ IR (unverifiable) — no receipt acknowledgment. Confirm state manually.');
484
513
  }
485
514
  else {
486
515
  console.log(`✓ Command sent: ${cmd}`);
@@ -581,6 +610,10 @@ Examples:
581
610
  .description('Describe a device by ID: metadata + supported commands + status fields (1 API call)')
582
611
  .argument('[deviceId]', 'Target device ID (or use --name)')
583
612
  .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
613
+ .option('--name-strategy <s>', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default: fuzzy)', stringArg('--name-strategy'))
614
+ .option('--name-type <type>', 'Narrow --name by device type', stringArg('--name-type'))
615
+ .option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
616
+ .option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
584
617
  .option('--live', 'Also fetch live status values and merge them into capabilities (costs 1 extra API call)')
585
618
  .addHelpText('after', `
586
619
  Makes a GET /v1.1/devices call to look up the device's type, then prints its
@@ -612,7 +645,12 @@ Examples:
612
645
  `)
613
646
  .action(async (deviceIdArg, options) => {
614
647
  try {
615
- const deviceId = resolveDeviceId(deviceIdArg, options.name);
648
+ const deviceId = resolveDeviceId(deviceIdArg, options.name, {
649
+ strategy: options.nameStrategy ?? 'fuzzy',
650
+ type: options.nameType,
651
+ category: options.nameCategory,
652
+ room: options.nameRoom,
653
+ });
616
654
  const result = await describeDevice(deviceId, options);
617
655
  const { device, isPhysical, typeName, controlType, catalog, capabilities, source, suggestedActions: picks } = result;
618
656
  if (isJsonMode()) {