jira-ai 0.6.12 → 0.9.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.
package/dist/cli.js CHANGED
@@ -30,7 +30,7 @@ import { checkForUpdate, formatUpdateMessage, checkForUpdateSync } from './lib/u
30
30
  import { CliError } from './types/errors.js';
31
31
  import { CommandError } from './lib/errors.js';
32
32
  import { ui } from './lib/ui.js';
33
- import { CreateTaskSchema, AddCommentSchema, UpdateDescriptionSchema, ConfluenceAddCommentSchema, RunJqlSchema, GetPersonWorklogSchema, GetIssueStatisticsSchema, validateOptions, IssueKeySchema, ProjectKeySchema, TimeframeSchema } from './lib/validation.js';
33
+ import { CreateTaskSchema, UpdateDescriptionSchema, ConfluenceAddCommentSchema, RunJqlSchema, GetPersonWorklogSchema, GetIssueStatisticsSchema, validateOptions, IssueKeySchema, ProjectKeySchema, TimeframeSchema } from './lib/validation.js';
34
34
  import { realpathSync } from 'fs';
35
35
  // Create CLI program
36
36
  const program = new Command();
@@ -94,78 +94,34 @@ program
94
94
  .command('logout')
95
95
  .description('Logout from all organizations')
96
96
  .action(() => authCommand({ logout: true }));
97
- // Organization commands
98
- const org = program
99
- .command('organization')
100
- .alias('org')
101
- .description('Manage Jira organization profiles');
102
- org
103
- .command('list')
104
- .description('List all saved Jira organization profiles, showing their aliases and associated host URLs.')
105
- .action(() => listOrganizations());
106
- org
107
- .command('use <alias>')
108
- .description('Switch the active Jira organization profile to the one specified by the alias.')
109
- .action((alias) => useOrganizationCommand(alias));
110
- org
111
- .command('remove <alias>')
112
- .description('Delete the saved credentials and profile for the specified organization alias.')
113
- .action((alias) => removeOrganizationCommand(alias));
114
- org
115
- .command('add <alias>')
116
- .description('Interactive prompt to add a new Jira organization profile with the given alias.')
117
- .action((alias) => authCommand({ alias }));
118
- // Me command
119
- program
120
- .command('me')
121
- .description('Show profile details for the currently authenticated user, including Jira host, display name, email, account ID, status, and time zone.')
122
- .action(withPermission('me', meCommand));
123
- // Projects command
124
- program
125
- .command('projects')
126
- .description('List all accessible Jira projects showing their key, name, ID, type, and project lead.')
127
- .action(withPermission('projects', projectsCommand));
128
- // List colleagues command
129
- program
130
- .command('list-colleagues [project-key]')
131
- .description('Search and list users within the organization or a specific project (if project-key is provided). Returns display name, email, and account ID.')
132
- .action(withPermission('list-colleagues', listColleaguesCommand, {
133
- validateArgs: (args) => {
134
- if (args[0]) {
135
- validateOptions(ProjectKeySchema, args[0]);
136
- }
137
- }
138
- }));
139
- // Task with details command
140
- program
141
- .command('task-with-details <task-id>')
142
- .description('Retrieve comprehensive issue data including key, summary, status (name, category), assignee, reporter, creation/update dates, due date, labels, parent/subtasks, description, and comments. Use --include-detailed-history to fetch a chronological log of all changes including field updates and status transitions.')
97
+ // =============================================================================
98
+ // ISSUE COMMANDS
99
+ // =============================================================================
100
+ const issue = program
101
+ .command('issue')
102
+ .description('Manage Jira issues');
103
+ issue
104
+ .command('get <issue-id>')
105
+ .description('Retrieve comprehensive issue data including key, summary, status, assignee, reporter, dates, labels, description, and comments.')
143
106
  .option('--include-detailed-history', 'Include the full history of task actions')
144
107
  .option('--history-limit <number>', 'Number of history entries to show (default: 50)', '50')
145
108
  .option('--history-offset <number>', 'Number of history entries to skip (default: 0)', '0')
146
- .action(withPermission('task-with-details', taskWithDetailsCommand, {
109
+ .action(withPermission('issue.get', taskWithDetailsCommand, {
147
110
  validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
148
111
  }));
149
- // Project statuses command
150
- program
151
- .command('project-statuses <project-id>')
152
- .description('Fetch all available workflow statuses for a given project. Returns status name, ID, category (To Do, In Progress, Done), and description.')
153
- .action(withPermission('project-statuses', projectStatusesCommand, {
154
- validateArgs: (args) => validateOptions(ProjectKeySchema, args[0])
155
- }));
156
- // List issue types command
157
- program
158
- .command('list-issue-types <project-key>')
159
- .description('List all issue types (Standard and Subtask) available for a project, providing their name, ID, hierarchy level, and description.')
160
- .action(withPermission('list-issue-types', listIssueTypesCommand, {
161
- validateArgs: (args) => validateOptions(ProjectKeySchema, args[0])
162
- }));
163
- // Run JQL command
164
- program
165
- .command('run-jql <jql-query>')
166
- .description('Execute a Jira Query Language (JQL) search. Returns a list of issues with their key, summary, status, assignee, and priority. Supports limiting results via --limit (default 50).')
112
+ issue
113
+ .command('create')
114
+ .description('Create a new Jira issue with specified title, project key, and issue type.')
115
+ .requiredOption('--title <title>', 'Issue title/summary')
116
+ .requiredOption('--project <project>', 'Project key (e.g., PROJ)')
117
+ .requiredOption('--issue-type <type>', 'Issue type (e.g., Task, Epic, Subtask)')
118
+ .option('--parent <key>', 'Parent issue key (required for subtasks)')
119
+ .action(withPermission('issue.create', createTaskCommand, { schema: CreateTaskSchema }));
120
+ issue
121
+ .command('search <jql-query>')
122
+ .description('Execute a JQL search query. Returns issues with key, summary, status, assignee, and priority.')
167
123
  .option('-l, --limit <number>', 'Maximum number of results (default: 50)', '50')
168
- .action(withPermission('run-jql', runJqlCommand, {
124
+ .action(withPermission('issue.search', runJqlCommand, {
169
125
  schema: RunJqlSchema,
170
126
  validateArgs: (args) => {
171
127
  if (typeof args[0] !== 'string' || args[0].trim() === '') {
@@ -173,27 +129,51 @@ program
173
129
  }
174
130
  }
175
131
  }));
176
- // Update description command
177
- program
178
- .command('update-description <task-id>')
179
- .description('Update a Jira task\'s description using content from a local Markdown file. Requires the task ID and a valid file path.')
132
+ issue
133
+ .command('transition <issue-id> <to-status>')
134
+ .description('Change the status of a Jira issue. The <to-status> can be either the status name or ID.')
135
+ .action(withPermission('issue.transition', transitionCommand, {
136
+ validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
137
+ }));
138
+ issue
139
+ .command('update <issue-id>')
140
+ .description('Update a Jira issue\'s description using content from a local Markdown file.')
180
141
  .requiredOption('--from-file <path>', 'Path to Markdown file')
181
- .action(withPermission('update-description', updateDescriptionCommand, {
142
+ .action(withPermission('issue.update', updateDescriptionCommand, {
182
143
  schema: UpdateDescriptionSchema,
183
144
  validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
184
145
  }));
185
- // Add comment command
186
- program
187
- .command('add-comment')
188
- .description('Add a new comment to a Jira issue using content from a local Markdown file. Requires the issue key and a valid file path.')
189
- .requiredOption('--file-path <path>', 'Path to Markdown file')
190
- .requiredOption('--issue-key <key>', 'Jira issue key (e.g., PS-123)')
191
- .action(withPermission('add-comment', addCommentCommand, { schema: AddCommentSchema }));
192
- // Add label command
193
- program
194
- .command('add-label-to-issue <task-id> <labels>')
195
- .description('Add one or more labels (comma-separated) to a specific Jira issue.')
196
- .action(withPermission('add-label-to-issue', addLabelCommand, {
146
+ issue
147
+ .command('comment <issue-id>')
148
+ .description('Add a new comment to a Jira issue using content from a local Markdown file.')
149
+ .requiredOption('--from-file <path>', 'Path to Markdown file')
150
+ .action(withPermission('issue.comment', (issueKey, options) => {
151
+ return addCommentCommand({ filePath: options.fromFile, issueKey });
152
+ }, {
153
+ schema: UpdateDescriptionSchema,
154
+ validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
155
+ }));
156
+ issue
157
+ .command('stats <issue-ids>')
158
+ .description('Calculate time-based metrics for one or more issues (comma-separated). Shows time logged, estimates, and status breakdown.')
159
+ .option('--full-breakdown', 'Display each status in its own column')
160
+ .action(withPermission('issue.stats', getIssueStatisticsCommand, {
161
+ schema: GetIssueStatisticsSchema
162
+ }));
163
+ issue
164
+ .command('assign <issue-id> <account-id>')
165
+ .description('Assign or reassign a Jira issue to a user. Use "null" as account-id to unassign.')
166
+ .action(withPermission('issue.assign', issueAssignCommand, {
167
+ validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
168
+ }));
169
+ // Issue label subcommands
170
+ const issueLabel = issue
171
+ .command('label')
172
+ .description('Manage issue labels');
173
+ issueLabel
174
+ .command('add <issue-id> <labels>')
175
+ .description('Add one or more labels (comma-separated) to a Jira issue.')
176
+ .action(withPermission('issue.label.add', addLabelCommand, {
197
177
  validateArgs: (args) => {
198
178
  validateOptions(IssueKeySchema, args[0]);
199
179
  if (typeof args[1] !== 'string' || args[1].trim() === '') {
@@ -201,11 +181,10 @@ program
201
181
  }
202
182
  }
203
183
  }));
204
- // Delete label command
205
- program
206
- .command('delete-label-from-issue <task-id> <labels>')
207
- .description('Remove one or more labels (comma-separated) from a specific Jira issue.')
208
- .action(withPermission('delete-label-from-issue', deleteLabelCommand, {
184
+ issueLabel
185
+ .command('remove <issue-id> <labels>')
186
+ .description('Remove one or more labels (comma-separated) from a Jira issue.')
187
+ .action(withPermission('issue.label.remove', deleteLabelCommand, {
209
188
  validateArgs: (args) => {
210
189
  validateOptions(IssueKeySchema, args[0]);
211
190
  if (typeof args[1] !== 'string' || args[1].trim() === '') {
@@ -213,83 +192,114 @@ program
213
192
  }
214
193
  }
215
194
  }));
216
- // Create task command
217
- program
218
- .command('create-task')
219
- .description('Create a new Jira issue with specified title, project key, and issue type. Optional --parent key for subtasks. Returns the key of the newly created issue.')
220
- .requiredOption('--title <title>', 'Issue title/summary')
221
- .requiredOption('--project <project>', 'Project key (e.g., PROJ)')
222
- .requiredOption('--issue-type <type>', 'Issue type (e.g., Task, Epic, Subtask)')
223
- .option('--parent <key>', 'Parent issue key (required for subtasks)')
224
- .action(withPermission('create-task', createTaskCommand, { schema: CreateTaskSchema }));
225
- // Transition command
226
- program
227
- .command('transition <task-id> <to-status>')
228
- .description('Change the status of a Jira task. The <to-status> can be either the status name or ID.')
229
- .action(withPermission('transition', transitionCommand, {
230
- validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
195
+ // =============================================================================
196
+ // PROJECT COMMANDS
197
+ // =============================================================================
198
+ const project = program
199
+ .command('project')
200
+ .description('Manage Jira projects');
201
+ project
202
+ .command('list')
203
+ .description('List all accessible Jira projects showing their key, name, ID, type, and project lead.')
204
+ .action(withPermission('project.list', projectsCommand));
205
+ project
206
+ .command('statuses <project-key>')
207
+ .description('Fetch all available workflow statuses for a project (To Do, In Progress, Done).')
208
+ .action(withPermission('project.statuses', projectStatusesCommand, {
209
+ validateArgs: (args) => validateOptions(ProjectKeySchema, args[0])
231
210
  }));
232
- // Issue commands
233
- const issue = program
234
- .command('issue')
235
- .description('Manage Jira issues');
236
- issue
237
- .command('assign <task-id> <account-id>')
238
- .description('Assign or reassign a Jira issue to a specific user using their Account ID. Use "null" as account-id to unassign.')
239
- .action(withPermission('issue', issueAssignCommand, {
240
- validateArgs: (args) => validateOptions(IssueKeySchema, args[0])
211
+ project
212
+ .command('types <project-key>')
213
+ .description('List all issue types (Standard and Subtask) available for a project.')
214
+ .action(withPermission('project.types', listIssueTypesCommand, {
215
+ validateArgs: (args) => validateOptions(ProjectKeySchema, args[0])
241
216
  }));
242
- // Get issue statistics command
243
- program
244
- .command('get-issue-statistics <task-ids>')
245
- .description('Calculate and display time-based metrics for one or more issues (comma-separated). Returns a table containing key, summary, total time logged, original estimate, and a detailed breakdown of duration spent in each status.')
246
- .option('--full-breakdown', 'Display each status in its own column')
247
- .action(withPermission('get-issue-statistics', getIssueStatisticsCommand, {
248
- schema: GetIssueStatisticsSchema
217
+ // =============================================================================
218
+ // USER COMMANDS
219
+ // =============================================================================
220
+ const user = program
221
+ .command('user')
222
+ .description('User information and worklogs');
223
+ user
224
+ .command('me')
225
+ .description('Show profile details for the currently authenticated user.')
226
+ .action(withPermission('user.me', meCommand));
227
+ user
228
+ .command('search [project-key]')
229
+ .description('Search and list users within the organization or a specific project.')
230
+ .action(withPermission('user.search', listColleaguesCommand, {
231
+ validateArgs: (args) => {
232
+ if (args[0]) {
233
+ validateOptions(ProjectKeySchema, args[0]);
234
+ }
235
+ }
249
236
  }));
250
- // Get person worklog command
251
- program
252
- .command('get-person-worklog <person> <timeframe>')
253
- .description('Retrieve worklogs for a specific user over a timeframe (e.g., \'7d\', \'2w\'). Returns a list of entries with date, issue key, summary, time spent, and comments. Supports --group-by-issue.')
237
+ user
238
+ .command('worklog <person> <timeframe>')
239
+ .description('Retrieve worklogs for a user over a timeframe (e.g., "7d", "2w").')
254
240
  .option('--group-by-issue', 'Group the output by issue')
255
- .action(withPermission('get-person-worklog', getPersonWorklogCommand, {
241
+ .action(withPermission('user.worklog', getPersonWorklogCommand, {
256
242
  schema: GetPersonWorklogSchema,
257
243
  validateArgs: (args) => {
258
244
  validateOptions(TimeframeSchema, args[1]);
259
245
  }
260
246
  }));
261
- // Confluence commands
247
+ // =============================================================================
248
+ // ORGANIZATION COMMANDS
249
+ // =============================================================================
250
+ const org = program
251
+ .command('org')
252
+ .alias('organization')
253
+ .description('Manage Jira organization profiles');
254
+ org
255
+ .command('list')
256
+ .description('List all saved Jira organization profiles.')
257
+ .action(() => listOrganizations());
258
+ org
259
+ .command('use <alias>')
260
+ .description('Switch the active Jira organization profile.')
261
+ .action((alias) => useOrganizationCommand(alias));
262
+ org
263
+ .command('remove <alias>')
264
+ .description('Delete credentials for the specified organization.')
265
+ .action((alias) => removeOrganizationCommand(alias));
266
+ org
267
+ .command('add <alias>')
268
+ .description('Add a new Jira organization profile.')
269
+ .action((alias) => authCommand({ alias }));
270
+ // =============================================================================
271
+ // CONFLUENCE COMMANDS
272
+ // =============================================================================
262
273
  const confl = program
263
274
  .command('confl')
275
+ .alias('confluence')
264
276
  .description('Interact with Confluence pages and content');
265
277
  confl
266
- .command('get-page <url>')
278
+ .command('get <url>')
267
279
  .description('Download and display Confluence page content and comments from a given URL.')
268
- .action(withPermission('confl', confluenceGetPageCommand, { skipValidation: false }));
280
+ .action(withPermission('confl.get', confluenceGetPageCommand, { skipValidation: false }));
269
281
  confl
270
282
  .command('spaces')
271
283
  .description('List all allowed Confluence spaces.')
272
- .action(withPermission('confl', confluenceListSpacesCommand, { skipValidation: false }));
284
+ .action(withPermission('confl.spaces', confluenceListSpacesCommand, { skipValidation: false }));
273
285
  confl
274
286
  .command('pages <space-key>')
275
287
  .description('Display a hierarchical tree view of pages within a specific space.')
276
- .action(withPermission('confl', confluenceGetSpacePagesHierarchyCommand, { skipValidation: false }));
288
+ .action(withPermission('confl.pages', confluenceGetSpacePagesHierarchyCommand, { skipValidation: false }));
277
289
  confl
278
- .command('create-page <space> <title> [parent-page]')
279
- .description('Create a new Confluence page')
280
- .action(withPermission('confl', confluenceCreatePageCommand, { skipValidation: false }));
290
+ .command('create <space> <title> [parent-page]')
291
+ .description('Create a new Confluence page.')
292
+ .action(withPermission('confl.create', confluenceCreatePageCommand, { skipValidation: false }));
281
293
  confl
282
- .command('add-comment')
283
- .argument('<url>', 'The full URL of the Confluence page.')
284
- .description('Add a new comment to a Confluence page using content from a local Markdown file.')
294
+ .command('comment <url>')
295
+ .description('Add a comment to a Confluence page using content from a Markdown file.')
285
296
  .option('-f, --from-file <path>', 'Path to the markdown file containing the comment content.')
286
- .action(withPermission('confl', confluenceAddCommentCommand, { schema: ConfluenceAddCommentSchema }));
297
+ .action(withPermission('confl.comment', confluenceAddCommentCommand, { schema: ConfluenceAddCommentSchema }));
287
298
  confl
288
- .command('update-description')
289
- .argument('<url>', 'The full URL of the Confluence page.')
290
- .description('Update the content of an existing Confluence page using a Markdown file.')
299
+ .command('update <url>')
300
+ .description('Update the content of a Confluence page using a Markdown file.')
291
301
  .option('-f, --from-file <path>', 'Path to the markdown file containing the new content.')
292
- .action(withPermission('confl', confluenceUpdateDescriptionCommand, { schema: UpdateDescriptionSchema }));
302
+ .action(withPermission('confl.update', confluenceUpdateDescriptionCommand, { schema: UpdateDescriptionSchema }));
293
303
  // About command (always allowed)
294
304
  program
295
305
  .command('about')
@@ -298,7 +308,7 @@ program
298
308
  // Settings command
299
309
  program
300
310
  .command('settings')
301
- .description('View, validate, or apply configuration settings. Use `settings` to view active config, `--validate <file>` to check a YAML file, or `--apply <file>` to update `~/.jira-ai/settings.yaml`.')
311
+ .description('View, validate, or apply configuration settings.')
302
312
  .option('--apply <path>', 'Validate and apply settings from a YAML file')
303
313
  .option('--validate <path>', 'Perform schema and deep validation of a settings YAML file')
304
314
  .option('--reset', 'Revert settings to default')
@@ -308,32 +318,44 @@ Examples:
308
318
  $ jira-ai settings --validate my-settings.yaml
309
319
  $ jira-ai settings --apply my-settings.yaml
310
320
  $ jira-ai settings --reset
311
-
312
- Settings File Structure:
313
- defaults:
321
+
322
+ Settings File Structure:
323
+ defaults:
324
+ allowed-jira-projects:
325
+ - all # Allow all projects
326
+ allowed-commands:
327
+ - all # Allow all commands
328
+ allowed-confluence-spaces:
329
+ - all # Allow all Confluence spaces
330
+
331
+ organizations:
332
+ work:
314
333
  allowed-jira-projects:
315
- - all # Allow all projects
334
+ - PROJ # Allow specific project
335
+ - key: PM # Project-specific config
336
+ commands:
337
+ - issue.get # Only allow reading issues
338
+ filters:
339
+ participated:
340
+ was_assignee: true
316
341
  allowed-commands:
317
- - all # Allow all commands globally
342
+ - issue # All issue commands
343
+ - project.list # Only project list
344
+ - user.me # Only user me
318
345
  allowed-confluence-spaces:
319
- - all # Allow all Confluence spaces
346
+ - DOCS
347
+
348
+ Command Groups (use in allowed-commands):
349
+ issue - get, create, search, transition, update, comment, stats, assign, label
350
+ project - list, statuses, types
351
+ user - me, search, worklog
352
+ org - list, use, add, remove
353
+ confl - get, spaces, pages, create, comment, update
320
354
 
321
- organizations:
322
- work:
323
- allowed-jira-projects:
324
- - PROJ # Allow specific project by key
325
- - key: PM # Project-specific configuration
326
- commands: # Limit commands for this project
327
- - task-with-details
328
- filters:
329
- participated: # Filter by user participation
330
- was_assignee: true
331
- was_reporter: true
332
- allowed-commands:
333
- - me
334
- - projects
335
- allowed-confluence-spaces:
336
- - SPACE1 # Allow specific Confluence space
355
+ Examples:
356
+ - "issue" → allows all issue subcommands
357
+ - "issue.get" → allows only issue get
358
+ - "issue.label" → allows issue label add and remove
337
359
  `)
338
360
  .action((options) => settingsCommand(options));
339
361
  /**
@@ -341,9 +363,10 @@ Examples:
341
363
  */
342
364
  export function configureCommandVisibility(program) {
343
365
  const isAuthorized = hasCredentials();
366
+ const alwaysVisibleCommands = ['auth', 'about', 'settings'];
344
367
  if (!isAuthorized) {
345
368
  program.commands.forEach(cmd => {
346
- if (cmd.name() !== 'auth' && cmd.name() !== 'about') {
369
+ if (!alwaysVisibleCommands.includes(cmd.name())) {
347
370
  cmd._hidden = true;
348
371
  }
349
372
  });
@@ -351,8 +374,9 @@ export function configureCommandVisibility(program) {
351
374
  }
352
375
  else {
353
376
  program.commands.forEach(cmd => {
354
- // auth and about are always visible
355
- if (cmd.name() !== 'auth' && cmd.name() !== 'about') {
377
+ if (!alwaysVisibleCommands.includes(cmd.name())) {
378
+ // For hierarchical commands, check if the group is allowed
379
+ // e.g., 'issue' command group is allowed if 'issue' or any 'issue.*' is allowed
356
380
  if (!isCommandAllowed(cmd.name())) {
357
381
  cmd._hidden = true;
358
382
  }
@@ -6,27 +6,44 @@ import chalk from 'chalk';
6
6
  import { CliError } from '../types/errors.js';
7
7
  import { SettingsSchema } from './validation.js';
8
8
  import { getCurrentOrganizationAlias } from './auth-storage.js';
9
+ // Mapping from old flat command names to new hierarchical paths
10
+ export const LEGACY_COMMAND_MAP = {
11
+ 'me': 'user.me',
12
+ 'projects': 'project.list',
13
+ 'task-with-details': 'issue.get',
14
+ 'project-statuses': 'project.statuses',
15
+ 'list-issue-types': 'project.types',
16
+ 'list-colleagues': 'user.search',
17
+ 'run-jql': 'issue.search',
18
+ 'update-description': 'issue.update',
19
+ 'add-comment': 'issue.comment',
20
+ 'add-label-to-issue': 'issue.label.add',
21
+ 'delete-label-from-issue': 'issue.label.remove',
22
+ 'create-task': 'issue.create',
23
+ 'transition': 'issue.transition',
24
+ 'get-issue-statistics': 'issue.stats',
25
+ 'get-person-worklog': 'user.worklog',
26
+ 'organization': 'org',
27
+ 'confluence': 'confl',
28
+ // Already hierarchical, keep as-is
29
+ 'issue': 'issue',
30
+ 'org': 'org',
31
+ 'confl': 'confl'
32
+ };
33
+ /**
34
+ * Migrate legacy command names to new hierarchical format
35
+ */
36
+ export function migrateCommandNames(commands) {
37
+ return commands.map(cmd => LEGACY_COMMAND_MAP[cmd] || cmd);
38
+ }
9
39
  export const DEFAULT_ORG_SETTINGS = {
10
40
  'allowed-jira-projects': ['all'],
11
41
  'allowed-commands': [
12
- 'me',
13
- 'projects',
14
- 'task-with-details',
15
- 'run-jql',
16
- 'list-issue-types',
17
- 'project-statuses',
18
- 'create-task',
19
- 'list-colleagues',
20
- 'add-comment',
21
- 'add-label-to-issue',
22
- 'delete-label-from-issue',
23
- 'get-issue-statistics',
24
- 'get-person-worklog',
25
- 'organization',
26
- 'transition',
27
- 'update-description',
28
- 'confluence',
29
- 'issue'
42
+ 'issue', // All issue commands (get, create, search, transition, update, comment, stats, assign, label)
43
+ 'project', // All project commands (list, statuses, types)
44
+ 'user', // All user commands (me, search, worklog)
45
+ 'org', // Organization management
46
+ 'confl' // Confluence commands
30
47
  ],
31
48
  'allowed-confluence-spaces': ['all']
32
49
  };
@@ -41,6 +58,8 @@ export function getSettingsPath() {
41
58
  }
42
59
  export function migrateSettings(settings) {
43
60
  // Migration logic: if old structure exists, move it to defaults
61
+ // Note: We keep command names as-is in storage (don't migrate them here)
62
+ // Command name migration happens at runtime in isCommandAllowed()
44
63
  if (settings.projects || settings.commands) {
45
64
  const migratedDefaults = {
46
65
  'allowed-jira-projects': settings.projects || DEFAULT_ORG_SETTINGS['allowed-jira-projects'],
@@ -149,6 +168,24 @@ export function isProjectAllowed(projectKey, orgAlias) {
149
168
  });
150
169
  return isAllowed;
151
170
  }
171
+ /**
172
+ * Check if a command matches any allowed command using hierarchical matching.
173
+ * e.g., if 'issue' is allowed, then 'issue.get', 'issue.label.add' are all allowed.
174
+ * If 'issue.label' is allowed, then 'issue.label.add' and 'issue.label.remove' are allowed.
175
+ */
176
+ function matchesHierarchicalCommand(commandPath, allowedCommands) {
177
+ // Check for 'all' permission
178
+ if (allowedCommands.includes('all'))
179
+ return true;
180
+ // Check hierarchical permissions (most specific to least specific)
181
+ const parts = commandPath.split('.');
182
+ for (let i = parts.length; i > 0; i--) {
183
+ const checkPath = parts.slice(0, i).join('.');
184
+ if (allowedCommands.includes(checkPath))
185
+ return true;
186
+ }
187
+ return false;
188
+ }
152
189
  export function isCommandAllowed(commandName, projectKey, orgAlias) {
153
190
  // about, auth, and settings are always allowed
154
191
  if (['about', 'auth', 'settings'].includes(commandName)) {
@@ -157,31 +194,38 @@ export function isCommandAllowed(commandName, projectKey, orgAlias) {
157
194
  const settings = getEffectiveSettings(orgAlias);
158
195
  if (!settings)
159
196
  return false;
197
+ // Normalize the command name being checked (convert legacy to new format)
198
+ const normalizedCommandName = LEGACY_COMMAND_MAP[commandName] || commandName;
199
+ // Migrate legacy command names in settings to new format for checking
200
+ const migratedAllowedCommands = migrateCommandNames(settings['allowed-commands']);
160
201
  if (projectKey) {
161
202
  let project = settings['allowed-jira-projects'].find(p => typeof p !== 'string' && p.key === projectKey);
162
203
  if (!project) {
163
204
  project = settings['allowed-jira-projects'].find(p => typeof p === 'string' && (p === 'all' || p === projectKey));
164
205
  }
165
206
  if (project && typeof project !== 'string' && project.commands) {
166
- return project.commands.includes(commandName);
207
+ const migratedProjectCommands = migrateCommandNames(project.commands);
208
+ return matchesHierarchicalCommand(normalizedCommandName, migratedProjectCommands);
167
209
  }
168
210
  }
169
211
  else {
170
212
  // For visibility/global check: allowed if in global list OR in any project-specific list
171
- const allowedGlobally = settings['allowed-commands'].includes('all') || settings['allowed-commands'].includes(commandName);
172
- if (allowedGlobally) {
213
+ if (matchesHierarchicalCommand(normalizedCommandName, migratedAllowedCommands)) {
173
214
  return true;
174
215
  }
175
- const allowedInAnyProject = settings['allowed-jira-projects'].some(p => typeof p !== 'string' && p.commands && p.commands.includes(commandName));
216
+ const allowedInAnyProject = settings['allowed-jira-projects'].some(p => {
217
+ if (typeof p !== 'string' && p.commands) {
218
+ const migratedProjectCommands = migrateCommandNames(p.commands);
219
+ return matchesHierarchicalCommand(normalizedCommandName, migratedProjectCommands);
220
+ }
221
+ return false;
222
+ });
176
223
  if (allowedInAnyProject) {
177
224
  return true;
178
225
  }
179
226
  return false;
180
227
  }
181
- if (settings['allowed-commands'].includes('all')) {
182
- return true;
183
- }
184
- return settings['allowed-commands'].includes(commandName);
228
+ return matchesHierarchicalCommand(normalizedCommandName, migratedAllowedCommands);
185
229
  }
186
230
  export function isConfluenceSpaceAllowed(spaceKey, orgAlias) {
187
231
  const settings = getEffectiveSettings(orgAlias);
@@ -87,9 +87,22 @@ export const ProjectFiltersSchema = z.object({
87
87
  }).optional(),
88
88
  jql: z.string().optional(),
89
89
  });
90
+ /**
91
+ * Schema for hierarchical command names (e.g., "issue", "issue.get", "issue.label.add")
92
+ * Also accepts 'all' for allowing all commands
93
+ */
94
+ export const HierarchicalCommandSchema = z.string().regex(/^(all|[a-z]+(\.[a-z]+)*)$/, 'Command must be "all" or lowercase dot-separated (e.g., "issue", "issue.get", "issue.label.add")');
95
+ // Default allowed commands using hierarchical structure
96
+ const DEFAULT_ALLOWED_COMMANDS = [
97
+ 'issue', // All issue commands
98
+ 'project', // All project commands
99
+ 'user', // All user commands
100
+ 'org', // Organization management
101
+ 'confl' // Confluence commands
102
+ ];
90
103
  export const ProjectConfigSchema = z.object({
91
104
  key: z.string().trim().min(1),
92
- commands: z.array(z.string()).optional(),
105
+ commands: z.array(z.string()).optional(), // Can be legacy or hierarchical
93
106
  filters: ProjectFiltersSchema.optional(),
94
107
  });
95
108
  export const ProjectSettingSchema = z.union([
@@ -98,7 +111,7 @@ export const ProjectSettingSchema = z.union([
98
111
  ]);
99
112
  export const OrganizationSettingsSchema = z.object({
100
113
  'allowed-jira-projects': z.array(ProjectSettingSchema).nullish().transform(val => val || ['all']),
101
- 'allowed-commands': z.array(z.string()).nullish().transform(val => val || ['me', 'projects', 'task-with-details', 'run-jql', 'list-issue-types', 'project-statuses', 'create-task', 'list-colleagues', 'add-comment', 'add-label-to-issue', 'delete-label-from-issue', 'get-issue-statistics', 'get-person-worklog', 'organization', 'transition', 'update-description', 'confluence']),
114
+ 'allowed-commands': z.array(z.string()).nullish().transform(val => val || DEFAULT_ALLOWED_COMMANDS),
102
115
  'allowed-confluence-spaces': z.array(z.string()).nullish().transform(val => val || ['all']),
103
116
  });
104
117
  export const SettingsSchema = z.object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jira-ai",
3
- "version": "0.6.12",
3
+ "version": "0.9.0",
4
4
  "description": "AI friendly Jira CLI to save context",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",