@@ -625,3 +625,174 @@ describe("harness validation", () => {
625625 expect ( SUPPORTED_HARNESSES . includes ( "" ) ) . toBe ( false ) ;
626626 } ) ;
627627} ) ;
628+
629+ // ── Claude Code plugin lifecycle decisions ──
630+
631+ describe ( "claude-code plugin install/update logic" , ( ) => {
632+ // Mirrors the decision logic in cli.ts:
633+ // - if plugin not in `claude plugin list` output → install
634+ // - else → run `claude plugin marketplace update` + `claude plugin update`
635+ function shouldInstall ( pluginListOutput : string , pluginName : string ) : boolean {
636+ return ! pluginListOutput . includes ( pluginName ) ;
637+ }
638+
639+ function shouldUpdate ( pluginListOutput : string , pluginName : string ) : boolean {
640+ return pluginListOutput . includes ( pluginName ) ;
641+ }
642+
643+ it ( "installs when plugin not present" , ( ) => {
644+ const out = "Installed plugins:\n ❯ rust-analyzer-lsp@claude-plugins-official\n Version: 1.0.0" ;
645+ expect ( shouldInstall ( out , "hindsight-memory" ) ) . toBe ( true ) ;
646+ expect ( shouldUpdate ( out , "hindsight-memory" ) ) . toBe ( false ) ;
647+ } ) ;
648+
649+ it ( "updates when plugin already present" , ( ) => {
650+ const out = "Installed plugins:\n ❯ hindsight-memory@vectorize-io-hindsight\n Version: 0.5.0" ;
651+ expect ( shouldInstall ( out , "hindsight-memory" ) ) . toBe ( false ) ;
652+ expect ( shouldUpdate ( out , "hindsight-memory" ) ) . toBe ( true ) ;
653+ } ) ;
654+
655+ it ( "detects plugin regardless of installed scope" , ( ) => {
656+ const userScope = " ❯ hindsight-memory@vectorize-io-hindsight\n Scope: user" ;
657+ const localScope = " ❯ hindsight-memory@vectorize-io-hindsight\n Scope: local" ;
658+ expect ( shouldUpdate ( userScope , "hindsight-memory" ) ) . toBe ( true ) ;
659+ expect ( shouldUpdate ( localScope , "hindsight-memory" ) ) . toBe ( true ) ;
660+ } ) ;
661+ } ) ;
662+
663+ describe ( "claude-code marketplace detection" , ( ) => {
664+ // Mirrors the marketplace-add decision: if neither name nor repo is in the
665+ // `claude plugin marketplace list` output, we run `claude plugin marketplace add`.
666+ function hasMarketplace ( out : string , name : string , repo : string ) : boolean {
667+ return out . includes ( name ) || out . includes ( repo ) ;
668+ }
669+
670+ const MARKETPLACE_NAME = "vectorize-io-hindsight" ;
671+ const MARKETPLACE_REPO = "vectorize-io/hindsight" ;
672+
673+ it ( "detects when marketplace already added by name" , ( ) => {
674+ const out = "Configured marketplaces:\n vectorize-io-hindsight (github: vectorize-io/hindsight)" ;
675+ expect ( hasMarketplace ( out , MARKETPLACE_NAME , MARKETPLACE_REPO ) ) . toBe ( true ) ;
676+ } ) ;
677+
678+ it ( "detects when marketplace already added by repo" , ( ) => {
679+ const out = "Configured marketplaces:\n some-other-name (github: vectorize-io/hindsight)" ;
680+ expect ( hasMarketplace ( out , MARKETPLACE_NAME , MARKETPLACE_REPO ) ) . toBe ( true ) ;
681+ } ) ;
682+
683+ it ( "returns false when marketplace not added" , ( ) => {
684+ const out = "Configured marketplaces:\n claude-plugins-official" ;
685+ expect ( hasMarketplace ( out , MARKETPLACE_NAME , MARKETPLACE_REPO ) ) . toBe ( false ) ;
686+ } ) ;
687+
688+ it ( "returns false on empty marketplace list" , ( ) => {
689+ expect ( hasMarketplace ( "" , MARKETPLACE_NAME , MARKETPLACE_REPO ) ) . toBe ( false ) ;
690+ } ) ;
691+ } ) ;
692+
693+ describe ( "claude-code allowed-tools merge" , ( ) => {
694+ // Mirrors the auto-approve logic that merges entries into ~/.claude/settings.json's allowedTools.
695+ function mergeAllowed ( existing : string [ ] , toAdd : string [ ] ) : { merged : string [ ] ; updated : boolean } {
696+ const merged = [ ...existing ] ;
697+ let updated = false ;
698+ for ( const tool of toAdd ) {
699+ if ( ! merged . includes ( tool ) ) {
700+ merged . push ( tool ) ;
701+ updated = true ;
702+ }
703+ }
704+ return { merged, updated } ;
705+ }
706+
707+ const HINDSIGHT_TOOLS = [
708+ "mcp__hindsight__*" ,
709+ "Skill(hindsight-memory:create-agent)" ,
710+ "Bash(ls ~/.self-driving-agents/*)" ,
711+ "Bash(cat ~/.self-driving-agents/*)" ,
712+ ] ;
713+
714+ it ( "adds all tools to empty allowedTools" , ( ) => {
715+ const { merged, updated } = mergeAllowed ( [ ] , HINDSIGHT_TOOLS ) ;
716+ expect ( updated ) . toBe ( true ) ;
717+ expect ( merged ) . toEqual ( HINDSIGHT_TOOLS ) ;
718+ } ) ;
719+
720+ it ( "preserves existing entries" , ( ) => {
721+ const { merged } = mergeAllowed ( [ "Bash(npm *)" , "Read" ] , HINDSIGHT_TOOLS ) ;
722+ expect ( merged ) . toContain ( "Bash(npm *)" ) ;
723+ expect ( merged ) . toContain ( "Read" ) ;
724+ expect ( merged ) . toContain ( "mcp__hindsight__*" ) ;
725+ } ) ;
726+
727+ it ( "does not duplicate when already present" , ( ) => {
728+ const existing = [ ...HINDSIGHT_TOOLS ] ;
729+ const { merged, updated } = mergeAllowed ( existing , HINDSIGHT_TOOLS ) ;
730+ expect ( updated ) . toBe ( false ) ;
731+ expect ( merged ) . toHaveLength ( HINDSIGHT_TOOLS . length ) ;
732+ } ) ;
733+
734+ it ( "only adds missing entries" , ( ) => {
735+ const existing = [ "mcp__hindsight__*" ] ;
736+ const { merged, updated } = mergeAllowed ( existing , HINDSIGHT_TOOLS ) ;
737+ expect ( updated ) . toBe ( true ) ;
738+ expect ( merged ) . toHaveLength ( HINDSIGHT_TOOLS . length ) ;
739+ } ) ;
740+ } ) ;
741+
742+ describe ( "claude-code Hindsight config persistence" , ( ) => {
743+ // Mirrors the config-write logic: if existing config has a connection
744+ // (hindsightApiUrl or llmProvider), don't prompt; otherwise prompt.
745+ function shouldPromptConnection ( config : Record < string , any > ) : boolean {
746+ return ! config . hindsightApiUrl && ! config . llmProvider ;
747+ }
748+
749+ function applyClaudeConfig (
750+ existing : Record < string , any > ,
751+ prompted : { apiUrl : string ; apiToken ?: string }
752+ ) : Record < string , any > {
753+ return {
754+ ...existing ,
755+ hindsightApiUrl : prompted . apiUrl ,
756+ hindsightApiToken : prompted . apiToken ,
757+ enableKnowledgeTools : true ,
758+ } ;
759+ }
760+
761+ it ( "prompts on first install (empty config)" , ( ) => {
762+ expect ( shouldPromptConnection ( { } ) ) . toBe ( true ) ;
763+ } ) ;
764+
765+ it ( "skips prompt when hindsightApiUrl already set" , ( ) => {
766+ expect ( shouldPromptConnection ( { hindsightApiUrl : "https://api.example.com" } ) ) . toBe ( false ) ;
767+ } ) ;
768+
769+ it ( "skips prompt when llmProvider already set (local daemon mode)" , ( ) => {
770+ expect ( shouldPromptConnection ( { llmProvider : "openai" } ) ) . toBe ( false ) ;
771+ } ) ;
772+
773+ it ( "writes Cloud connection on first install" , ( ) => {
774+ const result = applyClaudeConfig ( { } , {
775+ apiUrl : "https://api.hindsight.vectorize.io" ,
776+ apiToken : "hsk_abc" ,
777+ } ) ;
778+ expect ( result . hindsightApiUrl ) . toBe ( "https://api.hindsight.vectorize.io" ) ;
779+ expect ( result . hindsightApiToken ) . toBe ( "hsk_abc" ) ;
780+ expect ( result . enableKnowledgeTools ) . toBe ( true ) ;
781+ } ) ;
782+
783+ it ( "preserves other settings when applying connection" , ( ) => {
784+ const existing = { debug : true , retainEveryNTurns : 1 } ;
785+ const result = applyClaudeConfig ( existing , { apiUrl : "https://x.com" , apiToken : "t" } ) ;
786+ expect ( result . debug ) . toBe ( true ) ;
787+ expect ( result . retainEveryNTurns ) . toBe ( 1 ) ;
788+ expect ( result . hindsightApiUrl ) . toBe ( "https://x.com" ) ;
789+ } ) ;
790+
791+ it ( "always sets enableKnowledgeTools=true" , ( ) => {
792+ const result = applyClaudeConfig (
793+ { enableKnowledgeTools : false } ,
794+ { apiUrl : "https://x.com" , apiToken : "t" }
795+ ) ;
796+ expect ( result . enableKnowledgeTools ) . toBe ( true ) ;
797+ } ) ;
798+ } ) ;
0 commit comments