@simplysm/sd-claude 13.0.69 → 13.0.71

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 (78) hide show
  1. package/README.md +12 -601
  2. package/claude/agents/sd-api-reviewer.md +0 -1
  3. package/claude/agents/sd-code-reviewer.md +0 -1
  4. package/claude/agents/sd-code-simplifier.md +1 -1
  5. package/claude/agents/sd-security-reviewer.md +0 -1
  6. package/claude/refs/sd-angular.md +26 -26
  7. package/claude/refs/sd-orm-v12.md +17 -17
  8. package/claude/rules/sd-refs-linker.md +14 -14
  9. package/claude/sd-statusline.js +21 -21
  10. package/claude/skills/sd-api-name-review/SKILL.md +1 -2
  11. package/claude/skills/sd-brainstorm/SKILL.md +3 -4
  12. package/claude/skills/sd-check/SKILL.md +1 -2
  13. package/claude/skills/sd-commit/SKILL.md +2 -3
  14. package/claude/skills/sd-debug/SKILL.md +1 -2
  15. package/claude/skills/sd-discuss/SKILL.md +1 -3
  16. package/claude/skills/sd-document/SKILL.md +99 -0
  17. package/claude/skills/sd-document/extract_docx.py +92 -0
  18. package/claude/skills/sd-document/extract_pdf.py +102 -0
  19. package/claude/skills/sd-document/extract_pptx.py +77 -0
  20. package/claude/skills/sd-document/extract_xlsx.py +83 -0
  21. package/claude/skills/sd-email-analyze/SKILL.md +6 -6
  22. package/claude/skills/sd-plan/SKILL.md +1 -3
  23. package/claude/skills/sd-plan-dev/SKILL.md +94 -111
  24. package/claude/skills/sd-plan-dev/code-quality-reviewer-prompt.md +1 -1
  25. package/claude/skills/sd-plan-dev/final-review-prompt.md +1 -1
  26. package/claude/skills/sd-plan-dev/spec-reviewer-prompt.md +1 -1
  27. package/claude/skills/sd-readme/SKILL.md +107 -88
  28. package/claude/skills/sd-review/SKILL.md +14 -16
  29. package/claude/skills/sd-skill/SKILL.md +6 -317
  30. package/claude/skills/sd-skill/cso-guide.md +161 -0
  31. package/claude/skills/sd-skill/writing-guide.md +163 -0
  32. package/claude/skills/sd-tdd/SKILL.md +1 -3
  33. package/claude/skills/sd-use/SKILL.md +1 -3
  34. package/claude/skills/sd-worktree/SKILL.md +52 -2
  35. package/dist/commands/auth-add.d.ts +2 -0
  36. package/dist/commands/auth-add.d.ts.map +1 -0
  37. package/dist/commands/auth-add.js +32 -0
  38. package/dist/commands/auth-add.js.map +6 -0
  39. package/dist/commands/auth-list.d.ts +2 -0
  40. package/dist/commands/auth-list.d.ts.map +1 -0
  41. package/dist/commands/auth-list.js +37 -0
  42. package/dist/commands/auth-list.js.map +6 -0
  43. package/dist/commands/auth-remove.d.ts +2 -0
  44. package/dist/commands/auth-remove.d.ts.map +1 -0
  45. package/dist/commands/auth-remove.js +22 -0
  46. package/dist/commands/auth-remove.js.map +6 -0
  47. package/dist/commands/auth-use.d.ts +2 -0
  48. package/dist/commands/auth-use.d.ts.map +1 -0
  49. package/dist/commands/auth-use.js +33 -0
  50. package/dist/commands/auth-use.js.map +6 -0
  51. package/dist/commands/auth-utils.d.ts +11 -0
  52. package/dist/commands/auth-utils.d.ts.map +1 -0
  53. package/dist/commands/auth-utils.js +57 -0
  54. package/dist/commands/auth-utils.js.map +6 -0
  55. package/dist/commands/install.js +3 -3
  56. package/dist/commands/install.js.map +1 -1
  57. package/dist/index.d.ts +5 -0
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +5 -0
  60. package/dist/index.js.map +1 -1
  61. package/dist/sd-claude.js +68 -3
  62. package/dist/sd-claude.js.map +1 -1
  63. package/package.json +3 -2
  64. package/scripts/sync-claude-assets.mjs +1 -1
  65. package/src/commands/auth-add.ts +36 -0
  66. package/src/commands/auth-list.ts +44 -0
  67. package/src/commands/auth-remove.ts +26 -0
  68. package/src/commands/auth-use.ts +53 -0
  69. package/src/commands/auth-utils.ts +65 -0
  70. package/src/commands/install.ts +19 -19
  71. package/src/index.ts +5 -0
  72. package/src/sd-claude.ts +81 -3
  73. package/tests/auth-add.spec.ts +74 -0
  74. package/tests/auth-list.spec.ts +175 -0
  75. package/tests/auth-remove.spec.ts +74 -0
  76. package/tests/auth-use.spec.ts +153 -0
  77. package/tests/auth-utils.spec.ts +173 -0
  78. package/claude/skills/sd-explore/SKILL.md +0 -78
@@ -0,0 +1,26 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { validateName, profileExists, getProfileDir, getCurrentUserID } from "./auth-utils.js";
4
+
5
+ export function runAuthRemove(name: string, homeDir?: string): void {
6
+ validateName(name);
7
+
8
+ if (!profileExists(name, homeDir)) {
9
+ throw new Error(`Profile '${name}' not found.`);
10
+ }
11
+
12
+ const profileDir = getProfileDir(name, homeDir);
13
+ const authJsonPath = path.join(profileDir, "auth.json");
14
+ const authData = JSON.parse(fs.readFileSync(authJsonPath, "utf-8")) as { userID: string };
15
+
16
+ const currentUserID = getCurrentUserID(homeDir);
17
+ if (currentUserID != null && currentUserID === authData.userID) {
18
+ // eslint-disable-next-line no-console
19
+ console.warn(`Warning: '${name}' is currently active.`);
20
+ }
21
+
22
+ fs.rmSync(profileDir, { recursive: true });
23
+
24
+ // eslint-disable-next-line no-console
25
+ console.log(`Removed profile '${name}'`);
26
+ }
@@ -0,0 +1,53 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import { validateName, profileExists, getProfileDir } from "./auth-utils.js";
5
+
6
+ export function runAuthUse(name: string, homeDir?: string): void {
7
+ validateName(name);
8
+
9
+ if (!profileExists(name, homeDir)) {
10
+ throw new Error(`Profile '${name}' not found.`);
11
+ }
12
+
13
+ const profileDir = getProfileDir(name, homeDir);
14
+ const home = homeDir ?? os.homedir();
15
+
16
+ // Read saved auth.json and credentials.json from profile directory
17
+ const authJson = JSON.parse(fs.readFileSync(path.join(profileDir, "auth.json"), "utf-8")) as {
18
+ oauthAccount: Record<string, unknown>;
19
+ userID: string;
20
+ };
21
+
22
+ const savedCredentials = JSON.parse(
23
+ fs.readFileSync(path.join(profileDir, "credentials.json"), "utf-8"),
24
+ ) as Record<string, unknown>;
25
+
26
+ // Check token expiry
27
+ const claudeAiOauth = savedCredentials["claudeAiOauth"] as { expiresAt?: number } | undefined;
28
+ if (claudeAiOauth?.expiresAt != null && claudeAiOauth.expiresAt < Date.now()) {
29
+ // eslint-disable-next-line no-console
30
+ console.warn("Warning: Token expired. Run /login after switching.");
31
+ }
32
+
33
+ // Read ~/.claude.json, replace ONLY oauthAccount and userID, write back
34
+ const claudeJsonPath = path.join(home, ".claude.json");
35
+ const claudeData = JSON.parse(fs.readFileSync(claudeJsonPath, "utf-8")) as Record<
36
+ string,
37
+ unknown
38
+ >;
39
+
40
+ claudeData["oauthAccount"] = authJson.oauthAccount;
41
+ claudeData["userID"] = authJson.userID;
42
+
43
+ fs.writeFileSync(claudeJsonPath, JSON.stringify(claudeData, null, 2));
44
+
45
+ // Replace ~/.claude/.credentials.json entirely with saved credentials
46
+ const credentialsPath = path.join(home, ".claude", ".credentials.json");
47
+ fs.writeFileSync(credentialsPath, JSON.stringify(savedCredentials, null, 2));
48
+
49
+ // Log the switch
50
+ const email = (authJson.oauthAccount["emailAddress"] as string | undefined) ?? "unknown";
51
+ // eslint-disable-next-line no-console
52
+ console.log(`Switched to ${name} (${email})`);
53
+ }
@@ -0,0 +1,65 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+
5
+ const NAME_PATTERN = /^[a-z0-9_-]+$/;
6
+
7
+ export function validateName(name: string): void {
8
+ if (!NAME_PATTERN.test(name)) {
9
+ throw new Error(
10
+ `Invalid name '${name}'. Use only lowercase letters, numbers, hyphens, underscores.`,
11
+ );
12
+ }
13
+ }
14
+
15
+ export function getProfileDir(name: string, homeDir?: string): string {
16
+ return path.join(homeDir ?? os.homedir(), ".sd-claude", "auth", name);
17
+ }
18
+
19
+ export function profileExists(name: string, homeDir?: string): boolean {
20
+ return fs.existsSync(getProfileDir(name, homeDir));
21
+ }
22
+
23
+ export function listProfiles(homeDir?: string): string[] {
24
+ const authDir = path.join(homeDir ?? os.homedir(), ".sd-claude", "auth");
25
+ if (!fs.existsSync(authDir)) {
26
+ return [];
27
+ }
28
+
29
+ return fs
30
+ .readdirSync(authDir, { withFileTypes: true })
31
+ .filter((entry) => entry.isDirectory())
32
+ .map((entry) => entry.name);
33
+ }
34
+
35
+ export function readCurrentAuth(homeDir?: string): {
36
+ oauthAccount: Record<string, unknown>;
37
+ userID: string;
38
+ } {
39
+ const claudeJsonPath = path.join(homeDir ?? os.homedir(), ".claude.json");
40
+ const data = JSON.parse(fs.readFileSync(claudeJsonPath, "utf-8")) as Record<string, unknown>;
41
+
42
+ const oauthAccount = data["oauthAccount"] as Record<string, unknown> | undefined;
43
+ const userID = data["userID"] as string | undefined;
44
+
45
+ if (oauthAccount == null || userID == null) {
46
+ throw new Error("Not logged in. Run /login first.");
47
+ }
48
+
49
+ return { oauthAccount, userID };
50
+ }
51
+
52
+ export function readCurrentCredentials(homeDir?: string): Record<string, unknown> {
53
+ const credentialsPath = path.join(homeDir ?? os.homedir(), ".claude", ".credentials.json");
54
+ return JSON.parse(fs.readFileSync(credentialsPath, "utf-8")) as Record<string, unknown>;
55
+ }
56
+
57
+ export function getCurrentUserID(homeDir?: string): string | undefined {
58
+ const claudeJsonPath = path.join(homeDir ?? os.homedir(), ".claude.json");
59
+ try {
60
+ const data = JSON.parse(fs.readFileSync(claudeJsonPath, "utf-8")) as Record<string, unknown>;
61
+ return data["userID"] as string | undefined;
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Claude Code 에셋을 프로젝트의 .claude/ 에 설치한다.
3
- * postinstall 스크립트 또는 `sd-claude install`로 실행.
2
+ * Installs Claude Code assets to the project's .claude/ directory.
3
+ * Executed via postinstall script or `sd-claude install`.
4
4
  */
5
5
  import fs from "fs";
6
6
  import path from "path";
@@ -9,23 +9,23 @@ import { fileURLToPath } from "url";
9
9
  export function runInstall(): void {
10
10
  try {
11
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
- // dist/commands/ → 패키지 루트
12
+ // dist/commands/ → package root
13
13
  const pkgRoot = path.resolve(__dirname, "../..");
14
14
  const sourceDir = path.join(pkgRoot, "claude");
15
15
 
16
16
  const projectRoot = findProjectRoot(__dirname);
17
17
  if (projectRoot == null) {
18
18
  // eslint-disable-next-line no-console
19
- console.log("[@simplysm/sd-claude] 프로젝트 루트를 찾을 없어 건너뜁니다.");
19
+ console.log("[@simplysm/sd-claude] Could not find project root, skipping installation.");
20
20
  return;
21
21
  }
22
22
 
23
- // simplysm 모노레포이면서 같은 major 버전이면 실행하지 않음
23
+ // Skip execution if this is the simplysm monorepo with the same major version
24
24
  if (isSimplysmMonorepoSameMajor(projectRoot, pkgRoot)) {
25
25
  return;
26
26
  }
27
27
 
28
- // 소스 디렉토리가 없으면 건너뜀 (모노레포 개발 환경에서는 claude/ 미존재)
28
+ // Skip if the source directory doesn't exist (claude/ may not exist in monorepo dev environment)
29
29
  if (!fs.existsSync(sourceDir)) {
30
30
  return;
31
31
  }
@@ -42,15 +42,15 @@ export function runInstall(): void {
42
42
  setupStatusLine(targetDir);
43
43
 
44
44
  // eslint-disable-next-line no-console
45
- console.log(`[@simplysm/sd-claude] ${sourceEntries.length}개의 sd-* 항목을 설치했습니다.`);
45
+ console.log(`[@simplysm/sd-claude] Installed ${sourceEntries.length} sd-* entries.`);
46
46
  } catch (err) {
47
- // postinstall 실패가 pnpm install 전체를 막지 않도록 에러 무시
47
+ // Ignore errors to prevent postinstall failure from blocking the entire pnpm install
48
48
  // eslint-disable-next-line no-console
49
- console.warn("[@simplysm/sd-claude] postinstall 경고:", (err as Error).message);
49
+ console.warn("[@simplysm/sd-claude] postinstall warning:", (err as Error).message);
50
50
  }
51
51
  }
52
52
 
53
- /** INIT_CWD 또는 node_modules 경로에서 프로젝트 루트를 찾는다. */
53
+ /** Finds the project root from INIT_CWD or node_modules path. */
54
54
  function findProjectRoot(dirname: string): string | undefined {
55
55
  if (process.env["INIT_CWD"] != null) {
56
56
  return process.env["INIT_CWD"];
@@ -62,7 +62,7 @@ function findProjectRoot(dirname: string): string | undefined {
62
62
  return idx !== -1 ? dirname.substring(0, idx) : undefined;
63
63
  }
64
64
 
65
- /** simplysm 모노레포이면서 major 버전이 같은지 확인한다. */
65
+ /** Checks if this is the simplysm monorepo with the same major version. */
66
66
  function isSimplysmMonorepoSameMajor(projectRoot: string, pkgRoot: string): boolean {
67
67
  const projectPkgPath = path.join(projectRoot, "package.json");
68
68
  if (!fs.existsSync(projectPkgPath)) return false;
@@ -85,18 +85,18 @@ function isSimplysmMonorepoSameMajor(projectRoot: string, pkgRoot: string): bool
85
85
  return projectMajor != null && projectMajor === sdClaudeMajor;
86
86
  }
87
87
 
88
- /** sd-* 항목을 재귀적으로 수집한다. */
88
+ /** Recursively collects sd-* entries. */
89
89
  function collectSdEntries(sourceDir: string): string[] {
90
90
  const entries: string[] = [];
91
91
 
92
- // 루트 레벨: sd-*
92
+ // Root level: sd-*
93
93
  for (const name of fs.readdirSync(sourceDir)) {
94
94
  if (name.startsWith("sd-")) {
95
95
  entries.push(name);
96
96
  }
97
97
  }
98
98
 
99
- // 서브 디렉토리: */sd-*
99
+ // Subdirectories: */sd-*
100
100
  for (const dirent of fs.readdirSync(sourceDir, { withFileTypes: true })) {
101
101
  if (!dirent.isDirectory() || dirent.name.startsWith("sd-")) continue;
102
102
  const subPath = path.join(sourceDir, dirent.name);
@@ -110,18 +110,18 @@ function collectSdEntries(sourceDir: string): string[] {
110
110
  return entries;
111
111
  }
112
112
 
113
- /** 기존 sd-* 항목을 삭제한다. */
113
+ /** Removes existing sd-* entries. */
114
114
  function cleanSdEntries(targetDir: string): void {
115
115
  if (!fs.existsSync(targetDir)) return;
116
116
 
117
- // 루트 레벨 sd-*
117
+ // Root level sd-*
118
118
  for (const name of fs.readdirSync(targetDir)) {
119
119
  if (name.startsWith("sd-")) {
120
120
  fs.rmSync(path.join(targetDir, name), { recursive: true });
121
121
  }
122
122
  }
123
123
 
124
- // 서브 디렉토리 */sd-*
124
+ // Subdirectories */sd-*
125
125
  for (const dirent of fs.readdirSync(targetDir, { withFileTypes: true })) {
126
126
  if (!dirent.isDirectory() || dirent.name.startsWith("sd-")) continue;
127
127
  const subPath = path.join(targetDir, dirent.name);
@@ -133,7 +133,7 @@ function cleanSdEntries(targetDir: string): void {
133
133
  }
134
134
  }
135
135
 
136
- /** sd-* 항목을 복사한다. */
136
+ /** Copies sd-* entries. */
137
137
  function copySdEntries(sourceDir: string, targetDir: string, entries: string[]): void {
138
138
  fs.mkdirSync(targetDir, { recursive: true });
139
139
  for (const entry of entries) {
@@ -144,7 +144,7 @@ function copySdEntries(sourceDir: string, targetDir: string, entries: string[]):
144
144
  }
145
145
  }
146
146
 
147
- /** settings.json에 statusLine 설정을 추가한다. */
147
+ /** Adds statusLine configuration to settings.json. */
148
148
  function setupStatusLine(targetDir: string): void {
149
149
  const settingsPath = path.join(targetDir, "settings.json");
150
150
  const sdStatusLineCommand = "node .claude/sd-statusline.js";
package/src/index.ts CHANGED
@@ -1,2 +1,7 @@
1
1
  // Commands
2
2
  export * from "./commands/install.js";
3
+ export * from "./commands/auth-utils.js";
4
+ export * from "./commands/auth-add.js";
5
+ export * from "./commands/auth-use.js";
6
+ export * from "./commands/auth-list.js";
7
+ export * from "./commands/auth-remove.js";
package/src/sd-claude.ts CHANGED
@@ -3,18 +3,96 @@
3
3
  import yargs from "yargs";
4
4
  import { hideBin } from "yargs/helpers";
5
5
  import { runInstall } from "./commands/install.js";
6
+ import { runAuthAdd } from "./commands/auth-add.js";
7
+ import { runAuthUse } from "./commands/auth-use.js";
8
+ import { runAuthList } from "./commands/auth-list.js";
9
+ import { runAuthRemove } from "./commands/auth-remove.js";
6
10
 
7
11
  await yargs(hideBin(process.argv))
8
- .help("help", "도움말")
12
+ .help("help", "Help")
9
13
  .alias("help", "h")
10
14
  .command(
11
15
  "install",
12
- "Claude Code 에셋을 프로젝트에 설치한다.",
16
+ "Installs Claude Code assets to the project.",
13
17
  (cmd) => cmd.version(false).hide("help"),
14
18
  () => {
15
19
  runInstall();
16
20
  },
17
21
  )
18
- .demandCommand(1, "명령어를 지정해주세요.")
22
+ .command("auth", "Manages Claude account profiles.", (cmd) =>
23
+ cmd
24
+ .version(false)
25
+ .hide("help")
26
+ .command(
27
+ "add <name>",
28
+ "Saves the currently logged-in account",
29
+ (sub) =>
30
+ sub.positional("name", {
31
+ type: "string",
32
+ demandOption: true,
33
+ }),
34
+ (argv) => {
35
+ try {
36
+ runAuthAdd(argv.name);
37
+ } catch (err) {
38
+ // eslint-disable-next-line no-console
39
+ console.error((err as Error).message);
40
+ process.exit(1);
41
+ }
42
+ },
43
+ )
44
+ .command(
45
+ "use <name>",
46
+ "Switches to a saved account",
47
+ (sub) =>
48
+ sub.positional("name", {
49
+ type: "string",
50
+ demandOption: true,
51
+ }),
52
+ (argv) => {
53
+ try {
54
+ runAuthUse(argv.name);
55
+ } catch (err) {
56
+ // eslint-disable-next-line no-console
57
+ console.error((err as Error).message);
58
+ process.exit(1);
59
+ }
60
+ },
61
+ )
62
+ .command(
63
+ "list",
64
+ "Displays the list of saved accounts",
65
+ (sub) => sub,
66
+ () => {
67
+ try {
68
+ runAuthList();
69
+ } catch (err) {
70
+ // eslint-disable-next-line no-console
71
+ console.error((err as Error).message);
72
+ process.exit(1);
73
+ }
74
+ },
75
+ )
76
+ .command(
77
+ "remove <name>",
78
+ "Removes a saved account",
79
+ (sub) =>
80
+ sub.positional("name", {
81
+ type: "string",
82
+ demandOption: true,
83
+ }),
84
+ (argv) => {
85
+ try {
86
+ runAuthRemove(argv.name);
87
+ } catch (err) {
88
+ // eslint-disable-next-line no-console
89
+ console.error((err as Error).message);
90
+ process.exit(1);
91
+ }
92
+ },
93
+ )
94
+ .demandCommand(1, "Please specify an auth subcommand."),
95
+ )
96
+ .demandCommand(1, "Please specify a command.")
19
97
  .strict()
20
98
  .parse();
@@ -0,0 +1,74 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import os from "os";
5
+ import { runAuthAdd } from "../src/commands/auth-add";
6
+
7
+ describe("runAuthAdd", () => {
8
+ let tmpDir: string;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sd-claude-auth-add-test-"));
12
+
13
+ // Set up .claude.json with oauthAccount and userID
14
+ const claudeJson = {
15
+ oauthAccount: { emailAddress: "test@example.com", token: "oauth-token" },
16
+ userID: "user-123",
17
+ };
18
+ fs.writeFileSync(path.join(tmpDir, ".claude.json"), JSON.stringify(claudeJson));
19
+
20
+ // Set up .claude/.credentials.json
21
+ const claudeDir = path.join(tmpDir, ".claude");
22
+ fs.mkdirSync(claudeDir, { recursive: true });
23
+ const credentials = { accessToken: "access-abc", refreshToken: "refresh-xyz" };
24
+ fs.writeFileSync(path.join(claudeDir, ".credentials.json"), JSON.stringify(credentials));
25
+ });
26
+
27
+ afterEach(() => {
28
+ fs.rmSync(tmpDir, { recursive: true });
29
+ });
30
+
31
+ test("saves auth.json and credentials.json correctly", () => {
32
+ runAuthAdd("work", tmpDir);
33
+
34
+ const profileDir = path.join(tmpDir, ".sd-claude", "auth", "work");
35
+
36
+ // Verify auth.json
37
+ const authJson = JSON.parse(
38
+ fs.readFileSync(path.join(profileDir, "auth.json"), "utf-8"),
39
+ ) as Record<string, unknown>;
40
+ expect(authJson).toEqual({
41
+ oauthAccount: { emailAddress: "test@example.com", token: "oauth-token" },
42
+ userID: "user-123",
43
+ });
44
+
45
+ // Verify credentials.json
46
+ const credJson = JSON.parse(
47
+ fs.readFileSync(path.join(profileDir, "credentials.json"), "utf-8"),
48
+ ) as Record<string, unknown>;
49
+ expect(credJson).toEqual({
50
+ accessToken: "access-abc",
51
+ refreshToken: "refresh-xyz",
52
+ });
53
+ });
54
+
55
+ test("throws when profile already exists", () => {
56
+ const profileDir = path.join(tmpDir, ".sd-claude", "auth", "work");
57
+ fs.mkdirSync(profileDir, { recursive: true });
58
+
59
+ expect(() => runAuthAdd("work", tmpDir)).toThrow(
60
+ "Profile 'work' already exists. Remove it first with: sd-claude auth remove work",
61
+ );
62
+ });
63
+
64
+ test("throws with invalid name", () => {
65
+ expect(() => runAuthAdd("BAD NAME!", tmpDir)).toThrow("Invalid name");
66
+ });
67
+
68
+ test("throws when not logged in", () => {
69
+ // Overwrite .claude.json without oauthAccount/userID
70
+ fs.writeFileSync(path.join(tmpDir, ".claude.json"), JSON.stringify({ someField: "value" }));
71
+
72
+ expect(() => runAuthAdd("work", tmpDir)).toThrow("Not logged in");
73
+ });
74
+ });
@@ -0,0 +1,175 @@
1
+ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import os from "os";
5
+ import { runAuthList } from "../src/commands/auth-list";
6
+
7
+ describe("runAuthList", () => {
8
+ let tmpDir: string;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sd-claude-auth-list-test-"));
12
+ });
13
+
14
+ afterEach(() => {
15
+ fs.rmSync(tmpDir, { recursive: true });
16
+ vi.restoreAllMocks();
17
+ });
18
+
19
+ test("outputs 'No saved profiles.' when no profiles exist", () => {
20
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
21
+
22
+ runAuthList(tmpDir);
23
+
24
+ expect(spy).toHaveBeenCalledWith("No saved profiles.");
25
+ });
26
+
27
+ test("outputs profiles sorted alphabetically with active marker", () => {
28
+ const authDir = path.join(tmpDir, ".sd-claude", "auth");
29
+
30
+ // Create profile "beta"
31
+ const betaDir = path.join(authDir, "beta");
32
+ fs.mkdirSync(betaDir, { recursive: true });
33
+ fs.writeFileSync(
34
+ path.join(betaDir, "auth.json"),
35
+ JSON.stringify({
36
+ oauthAccount: { emailAddress: "beta@example.com", organizationName: "BetaCorp" },
37
+ userID: "user-beta",
38
+ }),
39
+ );
40
+ fs.writeFileSync(
41
+ path.join(betaDir, "credentials.json"),
42
+ JSON.stringify({ claudeAiOauth: { expiresAt: new Date("2025-06-20").getTime() } }),
43
+ );
44
+
45
+ // Create profile "alpha"
46
+ const alphaDir = path.join(authDir, "alpha");
47
+ fs.mkdirSync(alphaDir, { recursive: true });
48
+ fs.writeFileSync(
49
+ path.join(alphaDir, "auth.json"),
50
+ JSON.stringify({
51
+ oauthAccount: { emailAddress: "alpha@example.com", organizationName: "AlphaCorp" },
52
+ userID: "user-alpha",
53
+ }),
54
+ );
55
+ fs.writeFileSync(
56
+ path.join(alphaDir, "credentials.json"),
57
+ JSON.stringify({ claudeAiOauth: { expiresAt: new Date("2025-06-25").getTime() } }),
58
+ );
59
+
60
+ // Set current userID to "user-alpha"
61
+ fs.writeFileSync(path.join(tmpDir, ".claude.json"), JSON.stringify({ userID: "user-alpha" }));
62
+
63
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
64
+
65
+ runAuthList(tmpDir);
66
+
67
+ expect(spy).toHaveBeenCalledTimes(2);
68
+ // alpha comes first (alphabetical), and is active
69
+ expect(spy).toHaveBeenNthCalledWith(1, "* alpha (alpha@example.com) expires: 2025-06-25");
70
+ // beta is not active
71
+ expect(spy).toHaveBeenNthCalledWith(2, " beta (beta@example.com) expires: 2025-06-20");
72
+ });
73
+
74
+ test("shows email even when organizationName is missing", () => {
75
+ const authDir = path.join(tmpDir, ".sd-claude", "auth");
76
+
77
+ const profileDir = path.join(authDir, "personal");
78
+ fs.mkdirSync(profileDir, { recursive: true });
79
+ fs.writeFileSync(
80
+ path.join(profileDir, "auth.json"),
81
+ JSON.stringify({
82
+ oauthAccount: { emailAddress: "user@gmail.com" },
83
+ userID: "user-personal",
84
+ }),
85
+ );
86
+ fs.writeFileSync(
87
+ path.join(profileDir, "credentials.json"),
88
+ JSON.stringify({ claudeAiOauth: { expiresAt: new Date("2025-07-01").getTime() } }),
89
+ );
90
+
91
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
92
+
93
+ runAuthList(tmpDir);
94
+
95
+ expect(spy).toHaveBeenCalledWith(" personal (user@gmail.com) expires: 2025-07-01");
96
+ });
97
+
98
+ test("shows 'unknown' when expiresAt is missing", () => {
99
+ const authDir = path.join(tmpDir, ".sd-claude", "auth");
100
+
101
+ const profileDir = path.join(authDir, "noexpiry");
102
+ fs.mkdirSync(profileDir, { recursive: true });
103
+ fs.writeFileSync(
104
+ path.join(profileDir, "auth.json"),
105
+ JSON.stringify({
106
+ oauthAccount: { emailAddress: "noexp@example.com", organizationName: "SomeCorp" },
107
+ userID: "user-noexp",
108
+ }),
109
+ );
110
+ fs.writeFileSync(path.join(profileDir, "credentials.json"), JSON.stringify({}));
111
+
112
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
113
+
114
+ runAuthList(tmpDir);
115
+
116
+ expect(spy).toHaveBeenCalledWith(" noexpiry (noexp@example.com) expires: unknown");
117
+ });
118
+
119
+ test("marks active profile with * when userID matches", () => {
120
+ const authDir = path.join(tmpDir, ".sd-claude", "auth");
121
+
122
+ const profileDir = path.join(authDir, "work");
123
+ fs.mkdirSync(profileDir, { recursive: true });
124
+ fs.writeFileSync(
125
+ path.join(profileDir, "auth.json"),
126
+ JSON.stringify({
127
+ oauthAccount: { emailAddress: "work@company.com", organizationName: "WorkCorp" },
128
+ userID: "user-work",
129
+ }),
130
+ );
131
+ fs.writeFileSync(
132
+ path.join(profileDir, "credentials.json"),
133
+ JSON.stringify({ claudeAiOauth: { expiresAt: new Date("2025-12-31").getTime() } }),
134
+ );
135
+
136
+ // Set current userID to match
137
+ fs.writeFileSync(path.join(tmpDir, ".claude.json"), JSON.stringify({ userID: "user-work" }));
138
+
139
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
140
+
141
+ runAuthList(tmpDir);
142
+
143
+ expect(spy).toHaveBeenCalledWith("* work (work@company.com) expires: 2025-12-31");
144
+ });
145
+
146
+ test("non-active profile has space prefix instead of *", () => {
147
+ const authDir = path.join(tmpDir, ".sd-claude", "auth");
148
+
149
+ const profileDir = path.join(authDir, "other");
150
+ fs.mkdirSync(profileDir, { recursive: true });
151
+ fs.writeFileSync(
152
+ path.join(profileDir, "auth.json"),
153
+ JSON.stringify({
154
+ oauthAccount: { emailAddress: "other@example.com", organizationName: "OtherCorp" },
155
+ userID: "user-other",
156
+ }),
157
+ );
158
+ fs.writeFileSync(
159
+ path.join(profileDir, "credentials.json"),
160
+ JSON.stringify({ claudeAiOauth: { expiresAt: new Date("2025-08-15").getTime() } }),
161
+ );
162
+
163
+ // Set current userID to something different
164
+ fs.writeFileSync(
165
+ path.join(tmpDir, ".claude.json"),
166
+ JSON.stringify({ userID: "user-different" }),
167
+ );
168
+
169
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
170
+
171
+ runAuthList(tmpDir);
172
+
173
+ expect(spy).toHaveBeenCalledWith(" other (other@example.com) expires: 2025-08-15");
174
+ });
175
+ });