@knpkv/jira-cli 0.3.0 → 1.1.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.
Files changed (110) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +24 -9
  3. package/dist/JiraAuth.d.ts +9 -1
  4. package/dist/JiraAuth.d.ts.map +1 -1
  5. package/dist/JiraAuth.js +26 -8
  6. package/dist/JiraAuth.js.map +1 -1
  7. package/dist/JiraCliError.d.ts +30 -0
  8. package/dist/JiraCliError.d.ts.map +1 -1
  9. package/dist/JiraCliError.js +14 -0
  10. package/dist/JiraCliError.js.map +1 -1
  11. package/dist/SyncWorkspace.d.ts +34 -0
  12. package/dist/SyncWorkspace.d.ts.map +1 -0
  13. package/dist/SyncWorkspace.js +105 -0
  14. package/dist/SyncWorkspace.js.map +1 -0
  15. package/dist/bin.js +4 -5
  16. package/dist/bin.js.map +1 -1
  17. package/dist/commands/auth.d.ts.map +1 -1
  18. package/dist/commands/auth.js +49 -5
  19. package/dist/commands/auth.js.map +1 -1
  20. package/dist/commands/get.d.ts.map +1 -1
  21. package/dist/commands/get.js +2 -2
  22. package/dist/commands/get.js.map +1 -1
  23. package/dist/commands/index.d.ts +1 -2
  24. package/dist/commands/index.d.ts.map +1 -1
  25. package/dist/commands/index.js +1 -2
  26. package/dist/commands/index.js.map +1 -1
  27. package/dist/commands/issue.d.ts +8 -0
  28. package/dist/commands/issue.d.ts.map +1 -0
  29. package/dist/commands/issue.js +10 -0
  30. package/dist/commands/issue.js.map +1 -0
  31. package/dist/commands/layers.d.ts.map +1 -1
  32. package/dist/commands/layers.js +8 -1
  33. package/dist/commands/layers.js.map +1 -1
  34. package/dist/commands/search.d.ts.map +1 -1
  35. package/dist/commands/search.js +4 -4
  36. package/dist/commands/search.js.map +1 -1
  37. package/dist/commands/version.js +13 -13
  38. package/dist/commands/version.js.map +1 -1
  39. package/dist/internal/frontmatter.d.ts.map +1 -1
  40. package/dist/internal/frontmatter.js +14 -1
  41. package/dist/internal/frontmatter.js.map +1 -1
  42. package/dist/internal/sync/baseline.d.ts +11 -0
  43. package/dist/internal/sync/baseline.d.ts.map +1 -0
  44. package/dist/internal/sync/baseline.js +18 -0
  45. package/dist/internal/sync/baseline.js.map +1 -0
  46. package/dist/internal/sync/changes.d.ts +15 -0
  47. package/dist/internal/sync/changes.d.ts.map +1 -0
  48. package/dist/internal/sync/changes.js +72 -0
  49. package/dist/internal/sync/changes.js.map +1 -0
  50. package/dist/internal/sync/config.d.ts +12 -0
  51. package/dist/internal/sync/config.d.ts.map +1 -0
  52. package/dist/internal/sync/config.js +53 -0
  53. package/dist/internal/sync/config.js.map +1 -0
  54. package/dist/internal/sync/document.d.ts +9 -0
  55. package/dist/internal/sync/document.d.ts.map +1 -0
  56. package/dist/internal/sync/document.js +173 -0
  57. package/dist/internal/sync/document.js.map +1 -0
  58. package/dist/internal/sync/fieldValues.d.ts +30 -0
  59. package/dist/internal/sync/fieldValues.d.ts.map +1 -0
  60. package/dist/internal/sync/fieldValues.js +91 -0
  61. package/dist/internal/sync/fieldValues.js.map +1 -0
  62. package/dist/internal/sync/manifest.d.ts +12 -0
  63. package/dist/internal/sync/manifest.d.ts.map +1 -0
  64. package/dist/internal/sync/manifest.js +23 -0
  65. package/dist/internal/sync/manifest.js.map +1 -0
  66. package/dist/internal/sync/paths.d.ts +26 -0
  67. package/dist/internal/sync/paths.d.ts.map +1 -0
  68. package/dist/internal/sync/paths.js +22 -0
  69. package/dist/internal/sync/paths.js.map +1 -0
  70. package/dist/internal/sync/schemas.d.ts +128 -0
  71. package/dist/internal/sync/schemas.d.ts.map +1 -0
  72. package/dist/internal/sync/schemas.js +82 -0
  73. package/dist/internal/sync/schemas.js.map +1 -0
  74. package/dist/internal/sync/types.d.ts +144 -0
  75. package/dist/internal/sync/types.d.ts.map +1 -0
  76. package/dist/internal/sync/types.js +17 -0
  77. package/dist/internal/sync/types.js.map +1 -0
  78. package/package.json +7 -4
  79. package/skills/jira/SKILL.md +25 -13
  80. package/src/JiraAuth.ts +74 -14
  81. package/src/JiraCliError.ts +24 -0
  82. package/src/SyncWorkspace.ts +185 -0
  83. package/src/bin.ts +4 -6
  84. package/src/commands/auth.ts +59 -5
  85. package/src/commands/get.ts +2 -2
  86. package/src/commands/index.ts +1 -2
  87. package/src/commands/issue.ts +13 -0
  88. package/src/commands/layers.ts +8 -1
  89. package/src/commands/search.ts +4 -4
  90. package/src/commands/version.ts +15 -15
  91. package/src/internal/frontmatter.ts +15 -1
  92. package/src/internal/sync/baseline.ts +27 -0
  93. package/src/internal/sync/changes.ts +118 -0
  94. package/src/internal/sync/config.ts +76 -0
  95. package/src/internal/sync/document.ts +201 -0
  96. package/src/internal/sync/fieldValues.ts +145 -0
  97. package/src/internal/sync/manifest.ts +32 -0
  98. package/src/internal/sync/paths.ts +48 -0
  99. package/src/internal/sync/schemas.ts +103 -0
  100. package/src/internal/sync/types.ts +192 -0
  101. package/test/SyncWorkspace.test.ts +76 -0
  102. package/test/commandTree.test.ts +266 -0
  103. package/test/frontmatter.test.ts +69 -0
  104. package/test/integration.test.ts +187 -0
  105. package/test/syncChanges.test.ts +106 -0
  106. package/test/syncConfig.test.ts +138 -0
  107. package/test/syncDocument.test.ts +69 -0
  108. package/test/syncFieldValues.test.ts +101 -0
  109. package/vitest.config.integration.ts +17 -0
  110. package/vitest.config.ts +1 -0
package/src/JiraAuth.ts CHANGED
@@ -35,18 +35,23 @@ import {
35
35
  revokeToken
36
36
  } from "@knpkv/atlassian-common/auth"
37
37
  import {
38
- deleteToken,
38
+ type AuthProfile,
39
+ deleteActiveProfile,
40
+ deleteProfileBySelector,
39
41
  type FileSystemError,
40
42
  type HomeDirectoryError,
41
43
  HomeDirectoryLive,
42
44
  isTokenExpired,
45
+ loadActiveProfile,
46
+ loadActiveProfileToken,
43
47
  loadOAuthConfig,
44
- loadToken,
48
+ loadProfiles,
45
49
  type OAuthConfig,
46
50
  type OAuthToken,
47
51
  type OAuthUser,
48
52
  saveOAuthConfig,
49
- saveToken
53
+ saveProfileToken,
54
+ setActiveProfileBySelector
50
55
  } from "@knpkv/atlassian-common/config"
51
56
  import * as Console from "effect/Console"
52
57
  import * as Context from "effect/Context"
@@ -92,9 +97,16 @@ const TokenStorageLive = Layer.mergeAll(
92
97
  )
93
98
 
94
99
  // Wrap token storage operations with their required layers
95
- const loadTokenOp = () => loadToken(TOOL_NAME).pipe(Effect.provide(TokenStorageLive))
96
- const saveTokenOp = (token: OAuthToken) => saveToken(TOOL_NAME, token).pipe(Effect.provide(TokenStorageLive))
97
- const deleteTokenOp = () => deleteToken(TOOL_NAME).pipe(Effect.provide(TokenStorageLive))
100
+ const loadTokenOp = () => loadActiveProfileToken(TOOL_NAME).pipe(Effect.provide(TokenStorageLive))
101
+ const saveTokenOp = (token: OAuthToken) => saveProfileToken(TOOL_NAME, token).pipe(Effect.provide(TokenStorageLive))
102
+ const deleteTokenOp = () => deleteActiveProfile(TOOL_NAME).pipe(Effect.provide(TokenStorageLive))
103
+ const listProfilesOp = () =>
104
+ loadProfiles(TOOL_NAME).pipe(Effect.map((store) => store.profiles), Effect.provide(TokenStorageLive))
105
+ const loadActiveProfileOp = () => loadActiveProfile(TOOL_NAME).pipe(Effect.provide(TokenStorageLive))
106
+ const switchProfileOp = (selector: string) =>
107
+ setActiveProfileBySelector(TOOL_NAME, selector).pipe(Effect.provide(TokenStorageLive))
108
+ const removeProfileOp = (selector: string) =>
109
+ deleteProfileBySelector(TOOL_NAME, selector).pipe(Effect.provide(TokenStorageLive))
98
110
  const loadOAuthConfigOp = () => loadOAuthConfig(TOOL_NAME).pipe(Effect.provide(TokenStorageLive))
99
111
  const saveOAuthConfigOp = (config: OAuthConfig) =>
100
112
  saveOAuthConfig(TOOL_NAME, config).pipe(Effect.provide(TokenStorageLive))
@@ -167,6 +179,24 @@ export interface JiraAuthService {
167
179
  OAuthUser | null,
168
180
  FileSystemError | HomeDirectoryError | PlatformError.PlatformError
169
181
  >
182
+ /** Get active auth profile */
183
+ readonly getActiveProfile: () => Effect.Effect<
184
+ AuthProfile | null,
185
+ FileSystemError | HomeDirectoryError | PlatformError.PlatformError
186
+ >
187
+ /** List stored auth profiles */
188
+ readonly listProfiles: () => Effect.Effect<
189
+ ReadonlyArray<AuthProfile>,
190
+ FileSystemError | HomeDirectoryError | PlatformError.PlatformError
191
+ >
192
+ /** Switch active profile by ID, name, site URL, cloud ID, or account ID */
193
+ readonly switchProfile: (
194
+ selector: string
195
+ ) => Effect.Effect<AuthProfile | null, FileSystemError | HomeDirectoryError | PlatformError.PlatformError>
196
+ /** Remove stored profile by ID, name, site URL, cloud ID, or account ID */
197
+ readonly removeProfile: (
198
+ selector: string
199
+ ) => Effect.Effect<AuthProfile | null, FileSystemError | HomeDirectoryError | PlatformError.PlatformError>
170
200
  /** Check if user is logged in */
171
201
  readonly isLoggedIn: () => Effect.Effect<boolean, FileSystemError | HomeDirectoryError | PlatformError.PlatformError>
172
202
  }
@@ -403,14 +433,11 @@ const make = Effect.gen(function*() {
403
433
  return Redacted.make(refreshed.access_token)
404
434
  }
405
435
 
406
- // This fiber owns the refresh
407
- const config = yield* getConfig()
408
- yield* Console.log("Token expired, refreshing...")
409
-
410
- const result = yield* refreshTokenImpl(token, config).pipe(
411
- Effect.tap((refreshed) => Deferred.succeed(deferred, refreshed)),
412
- Effect.tapError((error) => Deferred.fail(deferred, error)),
413
- Effect.ensuring(Ref.set(refreshLock, Option.none())),
436
+ const refresh = Effect.gen(function*() {
437
+ const config = yield* getConfig()
438
+ yield* Console.log("Token expired, refreshing...")
439
+ return yield* refreshTokenImpl(token, config)
440
+ }).pipe(
414
441
  Effect.catchTag("OAuthError", (error) => {
415
442
  if (error.step === "refresh") {
416
443
  return Effect.gen(function*() {
@@ -427,6 +454,15 @@ const make = Effect.gen(function*() {
427
454
  })
428
455
  )
429
456
 
457
+ // This fiber owns the refresh. Complete the shared Deferred with the final
458
+ // transformed exit so waiters observe the same success or failure.
459
+ const exit = yield* refresh.pipe(
460
+ Effect.exit,
461
+ Effect.ensuring(Ref.set(refreshLock, Option.none()))
462
+ )
463
+ yield* Deferred.done(deferred, exit)
464
+ const result = yield* Deferred.await(deferred)
465
+
430
466
  return Redacted.make(result.access_token)
431
467
  })
432
468
 
@@ -463,6 +499,26 @@ const make = Effect.gen(function*() {
463
499
  return token?.user ?? null
464
500
  })
465
501
 
502
+ const getActiveProfile = (): Effect.Effect<
503
+ AuthProfile | null,
504
+ FileSystemError | HomeDirectoryError | PlatformError.PlatformError
505
+ > => loadActiveProfileOp()
506
+
507
+ const listProfiles = (): Effect.Effect<
508
+ ReadonlyArray<AuthProfile>,
509
+ FileSystemError | HomeDirectoryError | PlatformError.PlatformError
510
+ > => listProfilesOp()
511
+
512
+ const switchProfile = (
513
+ selector: string
514
+ ): Effect.Effect<AuthProfile | null, FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
515
+ switchProfileOp(selector)
516
+
517
+ const removeProfile = (
518
+ selector: string
519
+ ): Effect.Effect<AuthProfile | null, FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
520
+ removeProfileOp(selector)
521
+
466
522
  const isLoggedIn = (): Effect.Effect<boolean, FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
467
523
  Effect.gen(function*() {
468
524
  const token = yield* loadTokenOp()
@@ -478,6 +534,10 @@ const make = Effect.gen(function*() {
478
534
  getCloudId,
479
535
  getSiteUrl,
480
536
  getCurrentUser,
537
+ getActiveProfile,
538
+ listProfiles,
539
+ switchProfile,
540
+ removeProfile,
481
541
  isLoggedIn
482
542
  })
483
543
  })
@@ -42,3 +42,27 @@ export class WriteError extends Data.TaggedError("WriteError")<{
42
42
  readonly message: string
43
43
  readonly cause?: unknown
44
44
  }> {}
45
+
46
+ /**
47
+ * Error when reading or writing Jira Markdown Sync workspace state.
48
+ *
49
+ * @category Errors
50
+ */
51
+ export class SyncWorkspaceError extends Data.TaggedError("SyncWorkspaceError")<{
52
+ readonly message: string
53
+ readonly path?: string | undefined
54
+ readonly cause?: unknown
55
+ }> {}
56
+
57
+ /**
58
+ * Error when local Jira Markdown Sync configuration or data fails validation.
59
+ *
60
+ * @category Errors
61
+ */
62
+ export class SyncValidationError extends Data.TaggedError("SyncValidationError")<{
63
+ readonly message: string
64
+ readonly issueKey?: string | undefined
65
+ readonly field?: string | undefined
66
+ readonly path?: string | undefined
67
+ readonly cause?: unknown
68
+ }> {}
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Local workspace I/O for Jira Markdown Sync.
3
+ *
4
+ * @module
5
+ */
6
+ import * as Context from "effect/Context"
7
+ import * as Effect from "effect/Effect"
8
+ import * as FileSystem from "effect/FileSystem"
9
+ import * as Layer from "effect/Layer"
10
+ import * as Path from "effect/Path"
11
+ import { parseSyncBaseline, serializeSyncBaseline } from "./internal/sync/baseline.js"
12
+ import { makeDefaultWorkspaceConfig, parseWorkspaceConfig, serializeWorkspaceConfig } from "./internal/sync/config.js"
13
+ import { makeEmptyManifest, parseSyncManifest, serializeSyncManifest } from "./internal/sync/manifest.js"
14
+ import {
15
+ baselineFilePath,
16
+ conventionDocumentPath,
17
+ resolveWorkspacePaths,
18
+ type SyncWorkspacePaths
19
+ } from "./internal/sync/paths.js"
20
+ import type { SyncBaseline, SyncManifest, WorkspaceConfig } from "./internal/sync/types.js"
21
+ import { SyncWorkspaceError } from "./JiraCliError.js"
22
+
23
+ export interface InitWorkspaceInput {
24
+ readonly root: string
25
+ readonly siteUrl: string
26
+ }
27
+
28
+ export interface SyncWorkspaceShape {
29
+ readonly init: (input: InitWorkspaceInput) => Effect.Effect<SyncWorkspacePaths, SyncWorkspaceError>
30
+ readonly paths: (root: string, config?: Pick<WorkspaceConfig, "documentsDir">) => Effect.Effect<SyncWorkspacePaths>
31
+ readonly readConfig: (root: string) => Effect.Effect<WorkspaceConfig, SyncWorkspaceError>
32
+ readonly writeConfig: (root: string, config: WorkspaceConfig) => Effect.Effect<void, SyncWorkspaceError>
33
+ readonly readManifest: (root: string) => Effect.Effect<SyncManifest, SyncWorkspaceError>
34
+ readonly writeManifest: (root: string, manifest: SyncManifest) => Effect.Effect<void, SyncWorkspaceError>
35
+ readonly readBaseline: (root: string, issueId: string) => Effect.Effect<SyncBaseline, SyncWorkspaceError>
36
+ readonly writeBaseline: (
37
+ root: string,
38
+ baseline: SyncBaseline
39
+ ) => Effect.Effect<void, SyncWorkspaceError>
40
+ readonly conventionDocumentPath: (root: string, issueKey: string) => Effect.Effect<string, SyncWorkspaceError>
41
+ }
42
+
43
+ export class SyncWorkspace extends Context.Service<
44
+ SyncWorkspace,
45
+ SyncWorkspaceShape
46
+ >()("@knpkv/jira-cli/SyncWorkspace") {}
47
+
48
+ const mapWorkspaceError = (message: string, path?: string | undefined) => (cause: unknown) =>
49
+ new SyncWorkspaceError({ message, path, cause })
50
+
51
+ const make = Effect.gen(function*() {
52
+ const fs = yield* FileSystem.FileSystem
53
+ const path = yield* Path.Path
54
+
55
+ const paths: SyncWorkspaceShape["paths"] = (root, config) => Effect.succeed(resolveWorkspacePaths(path, root, config))
56
+
57
+ const ensureDir = (dir: string): Effect.Effect<void, SyncWorkspaceError> =>
58
+ fs.makeDirectory(dir, { recursive: true }).pipe(
59
+ Effect.mapError(mapWorkspaceError("Failed to create sync workspace directory", dir)),
60
+ Effect.asVoid
61
+ )
62
+
63
+ const writeFile = (filePath: string, content: string): Effect.Effect<void, SyncWorkspaceError> =>
64
+ Effect.gen(function*() {
65
+ const dir = path.dirname(filePath)
66
+ yield* ensureDir(dir)
67
+ const tempPath = `${filePath}.tmp.${Date.now()}`
68
+ yield* fs.writeFileString(tempPath, content).pipe(
69
+ Effect.mapError(mapWorkspaceError("Failed to write sync workspace file", filePath))
70
+ )
71
+ yield* fs.rename(tempPath, filePath).pipe(
72
+ Effect.mapError(mapWorkspaceError("Failed to replace sync workspace file", filePath))
73
+ )
74
+ })
75
+
76
+ const readFile = (filePath: string): Effect.Effect<string, SyncWorkspaceError> =>
77
+ fs.readFileString(filePath).pipe(
78
+ Effect.mapError(mapWorkspaceError("Failed to read sync workspace file", filePath))
79
+ )
80
+
81
+ const init: SyncWorkspaceShape["init"] = ({ root, siteUrl }) =>
82
+ Effect.gen(function*() {
83
+ const config = makeDefaultWorkspaceConfig(siteUrl)
84
+ const workspacePaths = resolveWorkspacePaths(path, root, config)
85
+ yield* ensureDir(workspacePaths.documentsDir)
86
+ yield* ensureDir(workspacePaths.metadataDir)
87
+ yield* ensureDir(workspacePaths.baselinesDir)
88
+ yield* ensureDir(workspacePaths.historyDir)
89
+ yield* writeFile(workspacePaths.configFile, serializeWorkspaceConfig(config))
90
+ yield* writeFile(workspacePaths.manifestFile, serializeSyncManifest(makeEmptyManifest(siteUrl)))
91
+ return workspacePaths
92
+ })
93
+
94
+ const readConfig: SyncWorkspaceShape["readConfig"] = (root) =>
95
+ Effect.gen(function*() {
96
+ const workspacePaths = resolveWorkspacePaths(path, root)
97
+ const content = yield* readFile(workspacePaths.configFile)
98
+ return yield* parseWorkspaceConfig(workspacePaths.configFile, content).pipe(
99
+ Effect.mapError((cause) =>
100
+ new SyncWorkspaceError({
101
+ message: cause.message,
102
+ path: "path" in cause ? cause.path : workspacePaths.configFile,
103
+ cause
104
+ })
105
+ )
106
+ )
107
+ })
108
+
109
+ const writeConfig: SyncWorkspaceShape["writeConfig"] = (root, config) =>
110
+ Effect.gen(function*() {
111
+ const workspacePaths = resolveWorkspacePaths(path, root, config)
112
+ yield* ensureDir(workspacePaths.metadataDir)
113
+ yield* writeFile(workspacePaths.configFile, serializeWorkspaceConfig(config))
114
+ })
115
+
116
+ const readManifest: SyncWorkspaceShape["readManifest"] = (root) =>
117
+ Effect.gen(function*() {
118
+ const workspacePaths = resolveWorkspacePaths(path, root)
119
+ const content = yield* readFile(workspacePaths.manifestFile)
120
+ return yield* parseSyncManifest(workspacePaths.manifestFile, content).pipe(
121
+ Effect.mapError((cause) =>
122
+ new SyncWorkspaceError({
123
+ message: cause.message,
124
+ path: "path" in cause ? cause.path : workspacePaths.manifestFile,
125
+ cause
126
+ })
127
+ )
128
+ )
129
+ })
130
+
131
+ const writeManifest: SyncWorkspaceShape["writeManifest"] = (root, manifest) =>
132
+ Effect.gen(function*() {
133
+ const workspacePaths = resolveWorkspacePaths(path, root)
134
+ yield* ensureDir(workspacePaths.metadataDir)
135
+ yield* writeFile(workspacePaths.manifestFile, serializeSyncManifest(manifest))
136
+ })
137
+
138
+ const readBaseline: SyncWorkspaceShape["readBaseline"] = (root, issueId) =>
139
+ Effect.gen(function*() {
140
+ const workspacePaths = resolveWorkspacePaths(path, root)
141
+ const filePath = baselineFilePath(path, workspacePaths, issueId)
142
+ const content = yield* readFile(filePath)
143
+ return yield* parseSyncBaseline(filePath, content).pipe(
144
+ Effect.mapError((cause) =>
145
+ new SyncWorkspaceError({
146
+ message: cause.message,
147
+ path: "path" in cause ? cause.path : filePath,
148
+ cause
149
+ })
150
+ )
151
+ )
152
+ })
153
+
154
+ const writeBaseline: SyncWorkspaceShape["writeBaseline"] = (root, baseline) =>
155
+ Effect.gen(function*() {
156
+ const workspacePaths = resolveWorkspacePaths(path, root)
157
+ const filePath = baselineFilePath(path, workspacePaths, baseline.issueId)
158
+ yield* ensureDir(workspacePaths.baselinesDir)
159
+ yield* writeFile(filePath, serializeSyncBaseline(baseline))
160
+ })
161
+
162
+ const getConventionDocumentPath: SyncWorkspaceShape["conventionDocumentPath"] = (root, issueKey) =>
163
+ Effect.gen(function*() {
164
+ const config = yield* readConfig(root)
165
+ const workspacePaths = resolveWorkspacePaths(path, root, config)
166
+ return conventionDocumentPath(path, workspacePaths, issueKey)
167
+ })
168
+
169
+ return SyncWorkspace.of({
170
+ init,
171
+ paths,
172
+ readConfig,
173
+ writeConfig,
174
+ readManifest,
175
+ writeManifest,
176
+ readBaseline,
177
+ writeBaseline,
178
+ conventionDocumentPath: getConventionDocumentPath
179
+ })
180
+ })
181
+
182
+ export const layer: Layer.Layer<SyncWorkspace, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
183
+ SyncWorkspace,
184
+ make
185
+ )
package/src/bin.ts CHANGED
@@ -17,11 +17,10 @@ import {
17
17
  AppLayer,
18
18
  authCommand,
19
19
  AuthOnlyLayer,
20
- getCommand,
21
20
  getLayerType,
22
21
  handleError,
22
+ issueCommand,
23
23
  MinimalLayer,
24
- searchCommand,
25
24
  versionCommand
26
25
  } from "./commands/index.js"
27
26
 
@@ -32,17 +31,16 @@ const skillsInstall = makeInstallCommand({
32
31
  })
33
32
 
34
33
  const skillsCommand = Command.make("skills", {}, () => Console.log("Usage: jira skills install")).pipe(
35
- Command.withDescription("Agent skill commands"),
34
+ Command.withDescription("Local write agent skill commands"),
36
35
  Command.withSubcommands([skillsInstall])
37
36
  )
38
37
 
39
38
  // === Main command ===
40
39
  const jira = Command.make("jira").pipe(
41
- Command.withDescription("Fetch Jira tickets and export to markdown"),
40
+ Command.withDescription("Jira CLI commands"),
42
41
  Command.withSubcommands([
43
42
  authCommand,
44
- getCommand,
45
- searchCommand,
43
+ issueCommand,
46
44
  skillsCommand,
47
45
  versionCommand
48
46
  ])
@@ -11,7 +11,7 @@
11
11
  import * as Console from "effect/Console"
12
12
  import * as Effect from "effect/Effect"
13
13
  import * as Option from "effect/Option"
14
- import { Command, Flag as Options, Prompt } from "effect/unstable/cli"
14
+ import { Argument as Args, Command, Flag as Options, Prompt } from "effect/unstable/cli"
15
15
  import { openBrowser } from "../internal/openBrowser.js"
16
16
  import { JiraAuth } from "../JiraAuth.js"
17
17
 
@@ -97,14 +97,65 @@ const logoutCommand = Command.make("logout", {}, () =>
97
97
  const statusCommand = Command.make("status", {}, () =>
98
98
  Effect.gen(function*() {
99
99
  const auth = yield* JiraAuth
100
- const user = yield* auth.getCurrentUser()
101
- if (user) {
102
- yield* Console.log(`Logged in as: ${user.name} (${user.email})`)
100
+ const profile = yield* auth.getActiveProfile()
101
+ if (profile) {
102
+ const user = profile.token.user
103
+ const account = user ? `${user.name} (${user.email})` : "unknown user"
104
+ yield* Console.log(`Active profile: ${profile.name}`)
105
+ yield* Console.log(`Account: ${account}`)
106
+ yield* Console.log(`Site: ${profile.token.site_url}`)
107
+ yield* Console.log(`Profile ID: ${profile.id}`)
103
108
  } else {
104
109
  yield* Console.log("Not logged in. Use 'jira auth login' to authenticate.")
105
110
  }
106
111
  })).pipe(Command.withDescription("Show authentication status"))
107
112
 
113
+ // === Auth profiles command ===
114
+ const profilesCommand = Command.make("profiles", {}, () =>
115
+ Effect.gen(function*() {
116
+ const auth = yield* JiraAuth
117
+ const [profiles, active] = yield* Effect.all([auth.listProfiles(), auth.getActiveProfile()])
118
+ if (profiles.length === 0) {
119
+ yield* Console.log("No auth profiles. Use 'jira auth login' to authenticate.")
120
+ return
121
+ }
122
+
123
+ for (const profile of profiles) {
124
+ const marker = active?.id === profile.id ? "*" : " "
125
+ yield* Console.log(`${marker} ${profile.id}`)
126
+ yield* Console.log(` ${profile.name}`)
127
+ yield* Console.log(` ${profile.token.site_url}`)
128
+ }
129
+ })).pipe(Command.withDescription("List stored auth profiles"))
130
+
131
+ const profileArg = Args.string("profile").pipe(
132
+ Args.withDescription("Profile ID, name, site URL, cloud ID, or account ID")
133
+ )
134
+
135
+ // === Auth use command ===
136
+ const useCommand = Command.make("use", { profile: profileArg }, ({ profile }) =>
137
+ Effect.gen(function*() {
138
+ const auth = yield* JiraAuth
139
+ const selected = yield* auth.switchProfile(profile)
140
+ if (!selected) {
141
+ yield* Console.log(`Profile not found: ${profile}`)
142
+ return
143
+ }
144
+ yield* Console.log(`Active profile: ${selected.name}`)
145
+ })).pipe(Command.withDescription("Switch active auth profile"))
146
+
147
+ // === Auth remove command ===
148
+ const removeCommand = Command.make("remove", { profile: profileArg }, ({ profile }) =>
149
+ Effect.gen(function*() {
150
+ const auth = yield* JiraAuth
151
+ const removed = yield* auth.removeProfile(profile)
152
+ if (!removed) {
153
+ yield* Console.log(`Profile not found: ${profile}`)
154
+ return
155
+ }
156
+ yield* Console.log(`Removed profile: ${removed.name}`)
157
+ })).pipe(Command.withDescription("Remove stored auth profile"))
158
+
108
159
  // === Auth command group ===
109
160
  export const authCommand = Command.make("auth").pipe(
110
161
  Command.withDescription("Manage OAuth authentication"),
@@ -113,6 +164,9 @@ export const authCommand = Command.make("auth").pipe(
113
164
  configureCommand,
114
165
  loginCommand,
115
166
  logoutCommand,
116
- statusCommand
167
+ statusCommand,
168
+ profilesCommand,
169
+ useCommand,
170
+ removeCommand
117
171
  ])
118
172
  )
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `jira get <key>` command — fetches a single issue and writes to Markdown.
2
+ * `jira issue get <key>` command — fetches a single issue and writes to Markdown.
3
3
  *
4
4
  * @internal
5
5
  */
@@ -39,4 +39,4 @@ export const getCommand = Command.make(
39
39
 
40
40
  yield* Console.log(`Done.`)
41
41
  })
42
- ).pipe(Command.withDescription("Get a single Jira issue by key"))
42
+ ).pipe(Command.withDescription("Read-only: get a single Jira issue by key"))
@@ -6,7 +6,6 @@
6
6
 
7
7
  export { authCommand } from "./auth.js"
8
8
  export { handleError } from "./errorHandler.js"
9
- export { getCommand } from "./get.js"
9
+ export { issueCommand } from "./issue.js"
10
10
  export { AppLayer, AuthOnlyLayer, getLayerType, MinimalLayer } from "./layers.js"
11
- export { searchCommand } from "./search.js"
12
11
  export { versionCommand } from "./version.js"
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `jira issue` command namespace.
3
+ *
4
+ * @internal
5
+ */
6
+ import { Command } from "effect/unstable/cli"
7
+ import { getCommand } from "./get.js"
8
+ import { searchCommand } from "./search.js"
9
+
10
+ export const issueCommand = Command.make("issue").pipe(
11
+ Command.withDescription("Read-only Jira issue commands"),
12
+ Command.withSubcommands([getCommand, searchCommand])
13
+ )
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * - **Lazy layer selection**: {@link getLayerType} inspects CLI arguments from `Stdio` to pick
7
7
  * the smallest layer needed — `"minimal"` for help/version, `"auth"` for auth commands,
8
- * `"full"` for search/get (which needs API client + issue service).
8
+ * `"full"` for issue/version reads and writes.
9
9
  * - **Dummy services**: Auth-only and minimal layers provide dying stubs
10
10
  * for unused services to satisfy the type system without initialization cost.
11
11
  *
@@ -61,6 +61,10 @@ const DummyJiraAuthLayer = Layer.succeed(
61
61
  getCloudId: () => Effect.die(new Error("Not configured")),
62
62
  getSiteUrl: () => Effect.die(new Error("Not configured")),
63
63
  getCurrentUser: () => Effect.succeed(null),
64
+ getActiveProfile: () => Effect.succeed(null),
65
+ listProfiles: () => Effect.succeed([]),
66
+ switchProfile: () => Effect.succeed(null),
67
+ removeProfile: () => Effect.succeed(null),
64
68
  isLoggedIn: () => Effect.succeed(false)
65
69
  })
66
70
  )
@@ -147,6 +151,9 @@ export const MinimalLayer = DummyIssueServiceLayer.pipe(
147
151
  */
148
152
  export const getLayerType = (args: ReadonlyArray<string>): "full" | "auth" | "minimal" => {
149
153
  const cmd = args[0]
154
+ if (args.includes("--help") || args.includes("-h")) {
155
+ return "minimal"
156
+ }
150
157
  if (cmd === "auth") {
151
158
  return "auth"
152
159
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `jira search` command — JQL search with multi/single Markdown output.
2
+ * `jira issue search` command — JQL search with multi/single Markdown output.
3
3
  *
4
4
  * @internal
5
5
  */
@@ -74,8 +74,8 @@ export const searchCommand = Command.make(
74
74
  query = jql.value
75
75
  } else {
76
76
  yield* Console.log("Error: Either a JQL query or --by-version must be provided.")
77
- yield* Console.log("Usage: jira search <jql>")
78
- yield* Console.log(" jira search --by-version <version>")
77
+ yield* Console.log("Usage: jira issue search <jql>")
78
+ yield* Console.log(" jira issue search --by-version <version>")
79
79
  return
80
80
  }
81
81
 
@@ -99,4 +99,4 @@ export const searchCommand = Command.make(
99
99
  yield* Console.log(`Exported ${issues.length} file(s) to ${outputDir}/`)
100
100
  }
101
101
  })
102
- ).pipe(Command.withDescription("Search Jira issues and export to markdown"))
102
+ ).pipe(Command.withDescription("Read-only: search Jira issues and export to markdown"))
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `jira version` command — list / view Jira project versions (releases) with
2
+ * `jira version` command — list / get Jira project versions (releases) with
3
3
  * Driver, Contributors and Approver fields resolved to display names, plus
4
4
  * mutations: edit the description and manage "Related work" links (the
5
5
  * Confluence pages surfaced on a release report).
@@ -126,13 +126,13 @@ const listCommand = Command.make("list", {
126
126
  v.approvers.map((a) => `${a.person.displayName}:${a.status}`).join("|") || "-"
127
127
  ].join(sep))
128
128
  }
129
- })).pipe(Command.withDescription("List versions for a Jira project"))
129
+ })).pipe(Command.withDescription("Read-only: list versions for a Jira project"))
130
130
 
131
- /** Cap on the number of ticket keys listed in the human `view` output. */
131
+ /** Cap on the number of ticket keys listed in the human `get` output. */
132
132
  const TICKET_KEYS_LIMIT = 20
133
133
 
134
- const viewCommand = Command.make(
135
- "view",
134
+ const getCommand = Command.make(
135
+ "get",
136
136
  { id: idArg, json: jsonOption, emails: emailsOption },
137
137
  ({ emails, id, json }) =>
138
138
  Effect.gen(function*() {
@@ -154,10 +154,10 @@ const viewCommand = Command.make(
154
154
  )
155
155
  yield* Console.log(`tickets (${version.tickets.length}): ${formatTicketKeys(version.tickets)}`)
156
156
  })
157
- ).pipe(Command.withDescription("Show a single Jira version"))
157
+ ).pipe(Command.withDescription("Read-only: get a single Jira version"))
158
158
 
159
159
  /**
160
- * Render a version's ticket keys for the human `view`: the first
160
+ * Render a version's ticket keys for the human `get`: the first
161
161
  * {@link TICKET_KEYS_LIMIT} keys, with a `(+M more)` suffix when truncated, or
162
162
  * `-` when there are none.
163
163
  */
@@ -174,7 +174,7 @@ const descriptionOption = Options.string("description").pipe(
174
174
  Options.withDescription("New version description")
175
175
  )
176
176
 
177
- const setCommand = Command.make("set", { id: idArg, description: descriptionOption, json: jsonOption }, ({
177
+ const updateCommand = Command.make("update", { id: idArg, description: descriptionOption, json: jsonOption }, ({
178
178
  description,
179
179
  id,
180
180
  json
@@ -190,10 +190,10 @@ const setCommand = Command.make("set", { id: idArg, description: descriptionOpti
190
190
  yield* Console.log(`Updated version ${version.name} (${version.id})`)
191
191
  yield* Console.log(`description: ${version.description ?? "-"}`)
192
192
  })).pipe(
193
- Command.withDescription("Update a version's description (requires manage:jira-project scope)")
193
+ Command.withDescription("Remote write: update a version's description (requires manage:jira-project scope)")
194
194
  )
195
195
 
196
- // === relatedwork ===
196
+ // === related-work ===
197
197
 
198
198
  const titleOption = Options.string("title").pipe(
199
199
  Options.withAlias("t"),
@@ -231,7 +231,7 @@ const relatedWorkListCommand = Command.make(
231
231
  yield* Console.log([w.category || "-", w.title ?? "-", w.url ?? "-"].join(sep))
232
232
  }
233
233
  })
234
- ).pipe(Command.withDescription("List a version's related-work links"))
234
+ ).pipe(Command.withDescription("Read-only: list a version's related-work links"))
235
235
 
236
236
  const relatedWorkAddCommand = Command.make("add", {
237
237
  id: idArg,
@@ -252,16 +252,16 @@ const relatedWorkAddCommand = Command.make("add", {
252
252
  yield* Console.log(`url: ${created.url ?? url}`)
253
253
  })).pipe(
254
254
  Command.withDescription(
255
- "Attach a related-work link (e.g. a Confluence page) to a version (requires manage:jira-project scope)"
255
+ "Remote write: attach a related-work link (e.g. a Confluence page) to a version (requires manage:jira-project scope)"
256
256
  )
257
257
  )
258
258
 
259
- const relatedWorkCommand = Command.make("relatedwork").pipe(
259
+ const relatedWorkCommand = Command.make("related-work").pipe(
260
260
  Command.withDescription("List or attach version related-work links (Confluence pages on the release report)"),
261
261
  Command.withSubcommands([relatedWorkListCommand, relatedWorkAddCommand])
262
262
  )
263
263
 
264
264
  export const versionCommand = Command.make("version").pipe(
265
- Command.withDescription("List, view, or edit Jira project versions (releases)"),
266
- Command.withSubcommands([listCommand, viewCommand, setCommand, relatedWorkCommand])
265
+ Command.withDescription("Jira version commands"),
266
+ Command.withSubcommands([listCommand, getCommand, updateCommand, relatedWorkCommand])
267
267
  )